Initial version

This commit is contained in:
2026-04-06 08:15:54 +02:00
parent 97b08ad6c8
commit 8db1a6a556
231 changed files with 96983 additions and 63 deletions
@@ -0,0 +1,22 @@
using EonaCat.LogStack.Core;
using System;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Base class for boosters that need configuration
/// </summary>
public abstract class BoosterBase : IBooster
{
protected BoosterBase(string name)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
}
public string Name { get; }
public abstract bool Boost(ref LogEventBuilder builder);
}
@@ -0,0 +1,25 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
namespace EonaCat.LogStack.Boosters
{
public sealed class AppBooster : BoosterBase
{
private static readonly string AppName = AppDomain.CurrentDomain.FriendlyName;
private static readonly string AppBase = AppDomain.CurrentDomain.BaseDirectory;
public AppBooster() : base("App") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("App", AppName);
builder.WithProperty("AppBase", AppBase);
return true;
}
}
}
@@ -0,0 +1,34 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds application name and version to log events
/// </summary>
public sealed class ApplicationBooster : BoosterBase
{
private readonly string _applicationName;
private readonly string? _version;
public ApplicationBooster(string applicationName, string? version = null) : base("Application")
{
_applicationName = applicationName ?? throw new ArgumentNullException(nameof(applicationName));
_version = version;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("Application", _applicationName);
if (_version != null)
{
builder.WithProperty("Version", _version);
}
return true;
}
}
@@ -0,0 +1,41 @@
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds custom properties from a callback
/// </summary>
public sealed class CallbackBooster : BoosterBase
{
private readonly Func<Dictionary<string, object?>> _propertiesCallback;
public CallbackBooster(string name, Func<Dictionary<string, object?>> propertiesCallback) : base(name)
{
_propertiesCallback = propertiesCallback ?? throw new ArgumentNullException(nameof(propertiesCallback));
}
public override bool Boost(ref LogEventBuilder builder)
{
try
{
var properties = _propertiesCallback();
if (properties != null)
{
foreach (var kvp in properties)
{
builder.WithProperty(kvp.Key, kvp.Value);
}
}
}
catch
{
// Swallow exceptions in boosters to prevent logging failures
}
return true;
}
}
@@ -0,0 +1,27 @@
using EonaCat.LogStack.Core;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds correlation ID from Activity or custom source
/// </summary>
public sealed class CorrelationIdBooster : BoosterBase
{
public CorrelationIdBooster() : base("CorrelationId") { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
var activity = Activity.Current;
if (activity != null)
{
builder.WithProperty("CorrelationId", activity.Id ?? activity.TraceId.ToString());
}
return true;
}
}
@@ -0,0 +1,37 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds a custom text property to log events
/// </summary>
public sealed class CustomTextBooster : BoosterBase
{
private readonly string _propertyName;
private readonly string _text;
/// <summary>
/// Creates a new booster that adds a custom text property to logs
/// </summary>
/// <param name="propertyName">The name of the property to add</param>
/// <param name="text">The text value to set</param>
public CustomTextBooster(string propertyName, string text)
: base("CustomText")
{
_propertyName = propertyName ?? throw new ArgumentNullException(nameof(propertyName));
_text = text ?? throw new ArgumentNullException(nameof(text));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty(_propertyName, _text);
return true;
}
}
}
@@ -0,0 +1,21 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class DateBooster : BoosterBase
{
public DateBooster() : base("Date") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("Date", DateTime.UtcNow.ToString("yyyy-MM-dd"));
return true;
}
}
}
@@ -0,0 +1,27 @@
using EonaCat.LogStack.Core;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds environment name to log events
/// </summary>
public sealed class EnvironmentBooster : BoosterBase
{
private readonly string _environmentName;
public EnvironmentBooster(string environmentName) : base("Environment")
{
_environmentName = environmentName ?? "Production";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("Environment", _environmentName);
return true;
}
}
@@ -0,0 +1,23 @@
using EonaCat.LogStack.Core;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class FrameworkBooster : BoosterBase
{
private static readonly string FrameworkDesc = RuntimeInformation.FrameworkDescription;
public FrameworkBooster() : base("Framework") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("Framework", FrameworkDesc);
return true;
}
}
}
@@ -0,0 +1,28 @@
using EonaCat.LogStack.Core;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Filters log events based on level
/// </summary>
public sealed class LevelFilterBooster : BoosterBase
{
private readonly LogLevel _minimumLevel;
public LevelFilterBooster(LogLevel minimumLevel) : base("LevelFilter")
{
_minimumLevel = minimumLevel;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
// Filter will be handled by the pipeline, this is a no-op booster
// Actual filtering happens in the logger pipeline based on configuration
return true;
}
}
@@ -0,0 +1,25 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds machine name to log events
/// </summary>
public sealed class MachineNameBooster : BoosterBase
{
private static readonly string MachineName = Environment.MachineName;
public MachineNameBooster() : base("MachineName") { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("MachineName", MachineName);
return true;
}
}
@@ -0,0 +1,22 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class MemoryBooster : BoosterBase
{
public MemoryBooster() : base("Memory") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
var memoryMB = GC.GetTotalMemory(false) / 1024 / 1024;
builder.WithProperty("Memory", memoryMB);
return true;
}
}
}
@@ -0,0 +1,23 @@
using EonaCat.LogStack.Core;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class OSBooster : BoosterBase
{
private static readonly string OSDesc = RuntimeInformation.OSDescription;
public OSBooster() : base("OS") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("OS", OSDesc);
return true;
}
}
}
@@ -0,0 +1,24 @@
using EonaCat.LogStack.Core;
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class ProcStartBooster : BoosterBase
{
private static readonly DateTime ProcessStart = Process.GetCurrentProcess().StartTime;
public ProcStartBooster() : base("ProcStart") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("ProcStart", ProcessStart);
return true;
}
}
}
@@ -0,0 +1,25 @@
using EonaCat.LogStack.Core;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds process ID to log events
/// </summary>
public sealed class ProcessIdBooster : BoosterBase
{
private static readonly int ProcessId = Process.GetCurrentProcess().Id;
public ProcessIdBooster() : base("ProcessId") { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("ProcessId", ProcessId);
return true;
}
}
@@ -0,0 +1,21 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class ThreadIdBooster : BoosterBase
{
public ThreadIdBooster() : base("ThreadId") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("ThreadId", Environment.CurrentManagedThreadId);
return true;
}
}
}
@@ -0,0 +1,21 @@
using EonaCat.LogStack.Core;
using System.Runtime.CompilerServices;
using System.Threading;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class ThreadNameBooster : BoosterBase
{
public ThreadNameBooster() : base("ThreadName") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("ThreadName", Thread.CurrentThread.Name ?? "n/a");
return true;
}
}
}
@@ -0,0 +1,21 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class TicksBooster : BoosterBase
{
public TicksBooster() : base("Ticks") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("Ticks", DateTime.UtcNow.Ticks);
return true;
}
}
}
@@ -0,0 +1,21 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class TimeBooster : BoosterBase
{
public TimeBooster() : base("Time") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("Time", DateTime.UtcNow.ToString("HH:mm:ss.fff"));
return true;
}
}
}
@@ -0,0 +1,36 @@
using EonaCat.LogStack.Core;
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Adds timestamp in multiple formats
/// </summary>
public sealed class TimestampBooster : BoosterBase
{
private readonly TimestampMode _mode;
public TimestampBooster(TimestampMode mode = TimestampMode.Utc) : base("Timestamp")
{
_mode = mode;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
var timestamp = _mode switch
{
TimestampMode.Local => DateTime.Now.Ticks,
TimestampMode.HighPrecision => Stopwatch.GetTimestamp(),
_ => DateTime.UtcNow.Ticks
};
builder.WithTimestamp(timestamp);
return true;
}
}
@@ -0,0 +1,25 @@
using EonaCat.LogStack.Core;
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class UptimeBooster : BoosterBase
{
private static readonly DateTime ProcessStart = Process.GetCurrentProcess().StartTime;
public UptimeBooster() : base("Uptime") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
var uptime = (DateTime.Now - ProcessStart).TotalSeconds;
builder.WithProperty("Uptime", uptime);
return true;
}
}
}
@@ -0,0 +1,23 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Boosters
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class UserBooster : BoosterBase
{
private static readonly string UserName = Environment.UserName;
public UserBooster() : base("User") { }
[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]
public override bool Boost(ref LogEventBuilder builder)
{
builder.WithProperty("User", UserName);
return true;
}
}
}
@@ -0,0 +1,74 @@
using System;
namespace EonaCat.LogStack;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Colors to use when writing to the console.
/// </summary>
public class ColorSchema
{
/// <summary>
/// The color to use for critical messages.
/// </summary>
public ColorScheme Critical = new(ConsoleColor.DarkRed, ConsoleColor.Black);
/// <summary>
/// The color to use for debug messages.
/// </summary>
public ColorScheme Debug = new(ConsoleColor.Green, ConsoleColor.Black);
/// <summary>
/// The color to use for error messages.
/// </summary>
public ColorScheme Error = new(ConsoleColor.Red, ConsoleColor.Black);
/// <summary>
/// The color to use for informational messages.
/// </summary>
public ColorScheme Info = new(ConsoleColor.Blue, ConsoleColor.Black);
/// <summary>
/// The color to use for emergency messages.
/// </summary>
public ColorScheme Trace = new(ConsoleColor.Cyan, ConsoleColor.Black);
/// <summary>
/// The color to use for alert messages.
/// </summary>
public ColorScheme Traffic = new(ConsoleColor.DarkMagenta, ConsoleColor.Black);
/// <summary>
/// The color to use for warning messages.
/// </summary>
public ColorScheme Warning = new(ConsoleColor.DarkYellow, ConsoleColor.Black);
}
/// <summary>
/// Color scheme for logging messages.
/// </summary>
public class ColorScheme
{
/// <summary>
/// Background color.
/// </summary>
public ConsoleColor Background = Console.BackgroundColor;
/// <summary>
/// Foreground color.
/// </summary>
public ConsoleColor Foreground = Console.ForegroundColor;
/// <summary>
/// Instantiates a new color scheme.
/// </summary>
/// <param name="foreground">Foreground color.</param>
/// <param name="background">Background color.</param>
public ColorScheme(ConsoleColor foreground, ConsoleColor background)
{
Foreground = foreground;
Background = background;
}
}
@@ -0,0 +1,11 @@
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
namespace EonaCat.LogStack.EonaCatLogStackCore
{
public enum CompressionFormat
{
None,
GZip,
}
}
@@ -0,0 +1,59 @@
namespace EonaCat.LogStack.Core;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Defines the severity level of log entries
/// </summary>
public enum LogLevel : byte
{
None = 0,
Trace = 1,
Debug = 2,
Information = 3,
Warning = 4,
Error = 5,
Critical = 6,
}
/// <summary>
/// Result of a log write operation
/// </summary>
public enum WriteResult : byte
{
Success = 0,
Dropped = 1,
Failed = 2,
FlowDisabled = 3,
LevelFiltered = 4,
NoBlastZone = 5
}
/// <summary>
/// Strategy for handling backpressure in flows
/// </summary>
public enum BackpressureStrategy : byte
{
/// <summary>Wait for capacity to become available</summary>
Wait = 0,
/// <summary>Drop the newest incoming message</summary>
DropNewest = 1,
/// <summary>Drop the oldest message in the queue</summary>
DropOldest = 2,
/// <summary>Block until space is available (may impact performance)</summary>
Block = 3
}
/// <summary>
/// Options for timestamp generation
/// </summary>
public enum TimestampMode : byte
{
Utc = 0,
Local = 1,
HighPrecision = 2
}
@@ -0,0 +1,14 @@
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
namespace EonaCat.LogStack.EonaCatLogStackCore
{
public enum FileOutputFormat
{
Text,
Json,
Xml,
Csv, // RFC-4180 CSV
StructuredJson, // Machine-readable JSON with correlation IDs
}
}
@@ -0,0 +1,400 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using EonaCat.LogStack.Flows;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Audit log severity filter only these levels are written to the audit trail.
/// </summary>
public enum AuditLevel
{
All,
WarningAndAbove,
ErrorAndAbove,
CriticalOnly,
}
/// <summary>
/// A tamper-evident, append-only audit flow.
///
/// Each entry is written as:
/// SEQ|ISO-TIMESTAMP|LEVEL|CATEGORY|MESSAGE|PROPS|HASH
///
/// Where HASH = SHA-256( previousHash + currentLineWithoutHash ).
/// This creates a hash-chain so any deletion or modification of a past
/// entry invalidates all subsequent hashes, making tampering detectable.
///
/// The file is opened with FileShare.Read only (no concurrent writers).
/// The flow is synchronous-by-design: audit entries must land on disk
/// before the method returns, so <see cref="BlastAsync"/> blocks until
/// the entry is flushed.
/// </summary>
public sealed class AuditFlow : FlowBase
{
private const string Delimiter = "|";
private const int HashLength = 64; // hex SHA-256
private readonly string _filePath;
private readonly AuditLevel _auditLevel;
private readonly bool _includeProperties;
private readonly object _writeLock = new object();
private readonly FileStream _stream;
private readonly StreamWriter _writer;
private long _sequence;
private string _previousHash;
private long _totalEntries;
public AuditFlow(
string directory,
string filePrefix = "audit",
AuditLevel auditLevel = AuditLevel.All,
LogLevel minimumLevel = LogLevel.Trace,
bool includeProperties = true)
: base("Audit:" + directory, minimumLevel)
{
if (directory == null)
{
throw new ArgumentNullException(nameof(directory));
}
if (filePrefix == null)
{
throw new ArgumentNullException(nameof(filePrefix));
}
_auditLevel = auditLevel;
_includeProperties = includeProperties;
// Resolve relative path
if (directory.StartsWith("./", StringComparison.Ordinal))
{
directory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, directory.Substring(2));
}
Directory.CreateDirectory(directory);
// One file per day, named with date stamp
string date = DateTime.UtcNow.ToString("yyyyMMdd");
_filePath = Path.Combine(directory, $"{filePrefix}_{Environment.MachineName}_{date}.audit");
// Exclusive write access
_stream = new FileStream(
_filePath,
FileMode.Append,
FileAccess.Write,
FileShare.Read, // allow external readers, but no other writers
bufferSize: 4096,
FileOptions.WriteThrough); // WriteThrough = no OS cache, hits disk immediately
_writer = new StreamWriter(_stream, Encoding.UTF8) { AutoFlush = true };
// Derive starting hash from the last line already in the file (for continuity)
_previousHash = ReadLastHash(directory, filePrefix, date);
_sequence = CountExistingLines(_filePath);
}
/// <summary>Path to the current audit file.</summary>
public string FilePath => _filePath;
/// <summary>Total entries written in this session.</summary>
public long TotalEntries => Interlocked.Read(ref _totalEntries);
/// <summary>
/// Verify the integrity of the audit file by replaying the hash chain.
/// Returns (true, null) if intact, (false, reason) if tampered.
/// </summary>
public static (bool ok, string reason) Verify(string filePath)
{
if (!File.Exists(filePath))
{
return (false, "File not found.");
}
string previousHash = new string('0', HashLength);
long expectedSeq = 1;
foreach (string raw in File.ReadLines(filePath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("#"))
{
continue;
}
int lastPipe = raw.LastIndexOf(Delimiter, StringComparison.Ordinal);
if (lastPipe < 0)
{
return (false, $"Malformed line (no delimiter): {Truncate(raw, 120)}");
}
string body = raw.Substring(0, lastPipe);
string storedHash = raw.Substring(lastPipe + 1).Trim();
if (storedHash.Length != HashLength)
{
return (false, $"Bad hash length on line {expectedSeq}: '{storedHash}'");
}
string computedHash = ComputeHash(previousHash, body);
if (!string.Equals(storedHash, computedHash, StringComparison.OrdinalIgnoreCase))
{
return (false, $"Hash mismatch on sequence {expectedSeq}. " +
$"Expected {computedHash}, found {storedHash}. " +
$"Entry may have been tampered with.");
}
// Verify sequence number (first field)
int firstPipe = body.IndexOf(Delimiter, StringComparison.Ordinal);
if (firstPipe > 0)
{
string seqStr = body.Substring(0, firstPipe);
if (long.TryParse(seqStr, out long seq) && seq != expectedSeq)
{
return (false, $"Sequence gap: expected {expectedSeq}, found {seq}.");
}
}
previousHash = computedHash;
expectedSeq++;
}
return (true, null);
}
public override Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (!PassesAuditLevel(logEvent.Level))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
WriteEntry(logEvent);
return Task.FromResult(WriteResult.Success);
}
public override Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
foreach (var e in logEvents.ToArray())
{
if (IsLogLevelEnabled(e) && PassesAuditLevel(e.Level))
{
WriteEntry(e);
}
}
return Task.FromResult(WriteResult.Success);
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
{
lock (_writeLock)
{
_writer.Flush();
_stream.Flush(flushToDisk: true);
}
return Task.CompletedTask;
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
lock (_writeLock)
{
try { _writer.Flush(); } catch { }
try { _stream.Flush(true); } catch { }
try { _writer.Dispose(); } catch { }
try { _stream.Dispose(); } catch { }
}
await base.DisposeAsync().ConfigureAwait(false);
}
private void WriteEntry(LogEvent log)
{
lock (_writeLock)
{
long seq = Interlocked.Increment(ref _sequence);
var sb = new StringBuilder(256);
sb.Append(seq);
sb.Append(Delimiter);
sb.Append(LogEvent.GetDateTime(log.Timestamp).ToString("O"));
sb.Append(Delimiter);
sb.Append(LevelString(log.Level));
sb.Append(Delimiter);
sb.Append(Escape(log.Category));
sb.Append(Delimiter);
sb.Append(Escape(log.Message.Length > 0 ? log.Message.ToString() : string.Empty));
if (log.Exception != null)
{
sb.Append(Delimiter);
sb.Append("EX=");
sb.Append(Escape(log.Exception.GetType().Name + ": " + log.Exception.Message));
}
if (_includeProperties && log.Properties.Count > 0)
{
sb.Append(Delimiter);
bool first = true;
foreach (var kv in log.Properties.ToArray())
{
if (!first)
{
sb.Append(';');
}
first = false;
sb.Append(Escape(kv.Key)).Append('=').Append(Escape(kv.Value?.ToString() ?? "null"));
}
}
string body = sb.ToString();
string hash = ComputeHash(_previousHash, body);
string line = body + Delimiter + hash;
_writer.WriteLine(line);
// AutoFlush=true + WriteThrough stream = immediate disk write
_previousHash = hash;
Interlocked.Increment(ref _totalEntries);
Interlocked.Increment(ref BlastedCount);
}
}
private bool PassesAuditLevel(LogLevel level) => _auditLevel switch
{
AuditLevel.All => true,
AuditLevel.WarningAndAbove => level >= LogLevel.Warning,
AuditLevel.ErrorAndAbove => level >= LogLevel.Error,
AuditLevel.CriticalOnly => level >= LogLevel.Critical,
_ => true
};
private static string LevelString(LogLevel level) => level switch
{
LogLevel.Trace => "TRACE",
LogLevel.Debug => "DEBUG",
LogLevel.Information => "INFO",
LogLevel.Warning => "WARN",
LogLevel.Error => "ERROR",
LogLevel.Critical => "CRITICAL",
_ => level.ToString().ToUpperInvariant()
};
/// <summary>Replace pipe characters inside field values so the delimiter stays unique.</summary>
private static string Escape(string value)
=> string.IsNullOrEmpty(value) ? string.Empty : value.Replace("|", "\\|").Replace("\r", "\\r").Replace("\n", "\\n");
public static string ComputeHash(string previousHash, string body)
{
if (string.IsNullOrEmpty(previousHash) || string.IsNullOrEmpty(body))
{
throw new ArgumentException("Input values cannot be null or empty.");
}
string inputString = previousHash + "|" + body;
byte[] input = Encoding.UTF8.GetBytes(inputString);
using (SHA256 sha = SHA256.Create())
{
byte[] digest = sha.ComputeHash(input);
return BitConverter.ToString(digest).Replace("-", "").ToLowerInvariant();
}
}
private static string ReadLastHash(string directory, string prefix, string date)
{
string path = Path.Combine(directory, $"{prefix}_{Environment.MachineName}_{date}.audit");
if (!File.Exists(path))
{
return new string('0', HashLength);
}
string lastLine = null;
// Open file with FileShare.ReadWrite to allow reading while it's being written to
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var reader = new StreamReader(fileStream, Encoding.UTF8))
{
// Read lines from the file
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith("#"))
{
lastLine = line;
}
}
}
if (lastLine == null)
{
return new string('0', HashLength);
}
int lastPipe = lastLine.LastIndexOf(Delimiter, StringComparison.Ordinal);
return lastPipe >= 0 ? lastLine.Substring(lastPipe + 1).Trim() : new string('0', HashLength);
}
private static long CountExistingLines(string path)
{
if (!File.Exists(path))
{
return 0;
}
long count = 0;
// Open the file with FileShare.ReadWrite to allow concurrent read/write access
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var reader = new StreamReader(fileStream, Encoding.UTF8))
{
while (!reader.EndOfStream)
{
var line = reader.ReadLine();
if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith("#"))
{
count++;
}
}
}
return count;
}
private static string Truncate(string s, int max)
=> s.Length <= max ? s : s.Substring(0, max) + "...";
}
}
@@ -0,0 +1,285 @@
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// console flow with color support and minimal allocations
/// Uses a ColorSchema for configurable colors
/// </summary>
public sealed class ConsoleFlow : FlowBase
{
private readonly bool _useColors;
private readonly TimestampMode _timestampMode;
private readonly StringBuilder _buffer = new(1024);
private readonly object _consoleLock = new();
private readonly ColorSchema _colors;
private readonly string _template;
private List<Action<LogEvent, StringBuilder>> _compiledTemplate;
public ConsoleFlow(
LogLevel minimumLevel = LogLevel.Trace,
bool useColors = true,
TimestampMode timestampMode = TimestampMode.Local,
ColorSchema? colorSchema = null,
string template = "[{ts}] [Host: {host}] [Category: {category}] [Thread: {thread}] [{logtype}] {message}{props}")
: base("Console", minimumLevel)
{
_useColors = useColors;
_timestampMode = timestampMode;
_colors = colorSchema ?? new ColorSchema();
_template = template ?? throw new ArgumentNullException(nameof(template));
CompileTemplate(_template);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
WriteToConsole(logEvent);
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
public override Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
foreach (var logEvent in logEvents.Span)
{
if (logEvent.Level >= MinimumLevel)
{
WriteToConsole(logEvent);
Interlocked.Increment(ref BlastedCount);
}
}
return Task.FromResult(WriteResult.Success);
}
private void WriteToConsole(LogEvent logEvent)
{
lock (_consoleLock)
{
_buffer.Clear();
foreach (var action in _compiledTemplate)
{
action(logEvent, _buffer);
}
if (_useColors && TryGetColor(logEvent.Level, out var color))
{
Console.ForegroundColor = color.Foreground;
}
Console.WriteLine(_buffer.ToString());
if (logEvent.Exception != null)
{
if (_useColors)
{
Console.ForegroundColor = ConsoleColor.DarkRed;
}
Console.WriteLine(logEvent.Exception.ToString());
if (_useColors)
{
Console.ResetColor();
}
}
if (_useColors)
{
Console.ResetColor();
}
}
}
private void CompileTemplate(string template)
{
_compiledTemplate = new List<Action<LogEvent, StringBuilder>>();
int pos = 0;
while (pos < template.Length)
{
int open = template.IndexOf('{', pos);
if (open < 0)
{
string lit = template.Substring(pos);
_compiledTemplate.Add((_, sb) => sb.Append(lit));
break;
}
if (open > pos)
{
string lit = template.Substring(pos, open - pos);
_compiledTemplate.Add((_, sb) => sb.Append(lit));
}
int close = template.IndexOf('}', open);
if (close < 0)
{
string lit = template.Substring(open);
_compiledTemplate.Add((_, sb) => sb.Append(lit));
break;
}
string token = template.Substring(open + 1, close - open - 1);
_compiledTemplate.Add(ResolveToken(token));
pos = close + 1;
}
}
private Action<LogEvent, StringBuilder> ResolveToken(string token)
{
switch (token.ToLowerInvariant())
{
case "ts":
return (log, sb) =>
sb.Append(LogEvent.GetDateTime(log.Timestamp)
.ToString("yyyy-MM-dd HH:mm:ss.fff"));
case "tz":
return (_, sb) =>
sb.Append(_timestampMode == TimestampMode.Local
? TimeZoneInfo.Local.StandardName
: "UTC");
case "host":
return (_, sb) => sb.Append(Environment.MachineName);
case "category":
return (log, sb) =>
{
if (!string.IsNullOrEmpty(log.Category))
{
sb.Append(log.Category);
}
};
case "thread":
return (_, sb) => sb.Append(Thread.CurrentThread.ManagedThreadId);
case "pid":
return (_, sb) => sb.Append(Process.GetCurrentProcess().Id);
case "message":
return (log, sb) => sb.Append(log.Message);
case "props":
return AppendProperties;
case "newline":
return (_, sb) => sb.AppendLine();
case "logtype":
return (log, sb) =>
{
var levelText = GetLevelText(log.Level);
if (_useColors && TryGetColor(log.Level, out var color))
{
Console.ForegroundColor = color.Foreground;
Console.BackgroundColor = color.Background;
Console.Write(sb.ToString());
Console.Write(levelText);
Console.ResetColor();
sb.Clear();
}
else
{
sb.Append(levelText);
}
};
default:
return (_, sb) => sb.Append('{').Append(token).Append('}');
}
}
private void AppendProperties(LogEvent log, StringBuilder sb)
{
if (log.Properties.Count == 0)
{
return;
}
sb.Append(" {");
bool first = true;
foreach (var prop in log.Properties)
{
if (!first)
{
sb.Append(", ");
}
sb.Append(prop.Key);
sb.Append('=');
sb.Append(prop.Value?.ToString() ?? "null");
first = false;
}
sb.Append('}');
}
private bool TryGetColor(LogLevel level, out ColorScheme color)
{
color = level switch
{
LogLevel.Trace => _colors.Trace,
LogLevel.Debug => _colors.Debug,
LogLevel.Information => _colors.Info,
LogLevel.Warning => _colors.Warning,
LogLevel.Error => _colors.Error,
LogLevel.Critical => _colors.Critical,
_ => _colors.Info
};
return color != null;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string GetLevelText(LogLevel level)
{
return level switch
{
LogLevel.Trace => "TRACE",
LogLevel.Debug => "DEBUG",
LogLevel.Information => "INFO",
LogLevel.Warning => "WARN",
LogLevel.Error => "ERROR",
LogLevel.Critical => "CRITICAL",
_ => "???"
};
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
{
// Console auto-flushes
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,233 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// database flow with batched inserts for any ADO.NET database
/// </summary>
public sealed class DatabaseFlow : FlowBase
{
private const int ChannelCapacity = 4096;
private readonly int _batchSize;
private readonly Channel<LogEvent> _channel;
private readonly Task _writerTask;
private readonly CancellationTokenSource _cts;
private readonly Func<DbConnection> _connectionFactory;
private readonly string _tableName;
public DatabaseFlow(
Func<DbConnection> connectionFactory,
string tableName = "Logs",
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace)
: base($"Database:{tableName}", minimumLevel)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_tableName = tableName;
_batchSize = batchSize <= 0 ? 1 : batchSize;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_writerTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try
{
await _writerTask.ConfigureAwait(false);
}
catch (OperationCanceledException) { }
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
try
{
while (await _channel.Reader.WaitToReadAsync(cancellationToken))
{
while (_channel.Reader.TryRead(out var logEvent))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize)
{
await WriteBatchAsync(batch, cancellationToken).ConfigureAwait(false);
batch.Clear();
}
}
if (batch.Count > 0)
{
await WriteBatchAsync(batch, cancellationToken).ConfigureAwait(false);
batch.Clear();
}
}
if (batch.Count > 0)
{
await WriteBatchAsync(batch, cancellationToken).ConfigureAwait(false);
batch.Clear();
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"DatabaseFlow error: {ex.Message}");
}
}
private async Task WriteBatchAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
using var connection = _connectionFactory();
await connection.OpenAsync(cancellationToken);
using var transaction = connection.BeginTransaction();
// Build a single SQL command with multiple inserts
var sb = new StringBuilder();
var parameters = new List<DbParameter>();
int paramIndex = 0;
foreach (var logEvent in batch)
{
sb.Append($"INSERT INTO {_tableName} (Timestamp, Level, Category, Message, ThreadId, Exception, Properties) VALUES (");
// Timestamp
var timestampParam = CreateParameter(connection, $"@p{paramIndex++}", LogEvent.GetDateTime(logEvent.Timestamp).ToString("O"));
parameters.Add(timestampParam);
sb.Append(timestampParam.ParameterName).Append(", ");
// Level
var levelParam = CreateParameter(connection, $"@p{paramIndex++}", logEvent.Level.ToString());
parameters.Add(levelParam);
sb.Append(levelParam.ParameterName).Append(", ");
// Category
var categoryParam = CreateParameter(connection, $"@p{paramIndex++}", logEvent.Category ?? string.Empty);
parameters.Add(categoryParam);
sb.Append(categoryParam.ParameterName).Append(", ");
// Message
var messageParam = CreateParameter(connection, $"@p{paramIndex++}", logEvent.Message.ToString());
parameters.Add(messageParam);
sb.Append(messageParam.ParameterName).Append(", ");
// ThreadId
var threadParam = CreateParameter(connection, $"@p{paramIndex++}", logEvent.ThreadId);
parameters.Add(threadParam);
sb.Append(threadParam.ParameterName).Append(", ");
// Exception
object exValue = logEvent.Exception != null
? JsonHelper.ToJson(new
{
type = logEvent.Exception.GetType().FullName,
message = logEvent.Exception.Message,
stackTrace = logEvent.Exception.StackTrace
})
: DBNull.Value;
var exParam = CreateParameter(connection, $"@p{paramIndex++}", exValue);
parameters.Add(exParam);
sb.Append(exParam.ParameterName).Append(", ");
// Properties
object propsValue = logEvent.Properties.Count > 0
? JsonHelper.ToJson(logEvent.Properties)
: DBNull.Value;
var propsParam = CreateParameter(connection, $"@p{paramIndex++}", propsValue);
parameters.Add(propsParam);
sb.Append(propsParam.ParameterName).Append(");");
}
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = sb.ToString();
foreach (var p in parameters)
{
command.Parameters.Add(p);
}
await command.ExecuteNonQueryAsync(cancellationToken);
transaction.Commit();
}
private static Dictionary<string, object?> ToDictionary(ReadOnlyMemory<KeyValuePair<string, object?>> properties)
{
var dict = new Dictionary<string, object?>();
foreach (var prop in properties.Span)
{
dict[prop.Key] = prop.Value;
}
return dict;
}
private static DbParameter CreateParameter(DbConnection connection, string name, object value)
{
var p = connection.CreateCommand().CreateParameter();
p.ParameterName = name;
p.Value = value ?? DBNull.Value;
return p;
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try
{
await _writerTask.ConfigureAwait(false);
}
catch { }
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,300 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using EonaCat.LogStack.Extensions;
using Microsoft.Extensions.Primitives;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Diagnostic counters snapshot emitted on a regular interval.
/// </summary>
public sealed class DiagnosticsSnapshot
{
public DateTime CapturedAt { get; internal set; }
public double CpuPercent { get; internal set; }
public long WorkingSetBytes { get; internal set; }
public long GcGen0 { get; internal set; }
public long GcGen1 { get; internal set; }
public long GcGen2 { get; internal set; }
public long ThreadCount { get; internal set; }
public long HandleCount { get; internal set; }
public double UptimeSeconds { get; internal set; }
public Dictionary<string, object> Custom { get; internal set; }
}
/// <summary>
/// A flow that periodically captures process diagnostics (CPU, memory, GC, threads)
/// and writes them as structured log events. Also acts as a pass-through: every
/// normal log event optionally gets runtime metrics injected as properties.
///
/// Additionally exposes an in-process <see cref="Counter"/> registry so application
/// code can record business metrics (request count, error rate, etc.) that are
/// flushed alongside diagnostic snapshots.
/// </summary>
public sealed class DiagnosticsFlow : FlowBase
{
/// <summary>Counter for business metrics.</summary>
public sealed class Counter
{
private long _value;
public string Name { get; }
public Counter(string name) { Name = name; }
public void Increment() { Interlocked.Increment(ref _value); }
public void IncrementBy(long delta) { Interlocked.Add(ref _value, delta); }
public void Reset() { Interlocked.Exchange(ref _value, 0); }
public long Value { get { return Interlocked.Read(ref _value); } }
}
private readonly ConcurrentDictionary<string, Counter> _counters
= new ConcurrentDictionary<string, Counter>(StringComparer.OrdinalIgnoreCase);
private readonly TimeSpan _snapshotInterval;
private readonly bool _injectIntoEvents;
private readonly bool _writeSnapshotEvents;
private readonly string _snapshotCategory;
private readonly IFlow _forwardTo;
private readonly Func<Dictionary<string, object>> _customMetricsFactory;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly Thread _samplerThread;
private readonly Stopwatch _uptime = Stopwatch.StartNew();
private volatile DiagnosticsSnapshot _latest;
private TimeSpan _lastCpuTime;
private DateTime _lastCpuSample;
private readonly Process _proc;
public DiagnosticsSnapshot LatestSnapshot { get { return _latest; } }
public DiagnosticsFlow(
TimeSpan snapshotInterval = default(TimeSpan),
bool injectIntoEvents = false,
bool writeSnapshotEvents = true,
string snapshotCategory = "Diagnostics",
IFlow forwardTo = null,
LogLevel minimumLevel = LogLevel.Trace,
Func<Dictionary<string, object>> customMetrics = null)
: base("Diagnostics", minimumLevel)
{
_snapshotInterval = snapshotInterval == default(TimeSpan)
? TimeSpan.FromSeconds(60)
: snapshotInterval;
_injectIntoEvents = injectIntoEvents;
_writeSnapshotEvents = writeSnapshotEvents;
_snapshotCategory = snapshotCategory ?? "Diagnostics";
_forwardTo = forwardTo;
_customMetricsFactory = customMetrics;
_proc = Process.GetCurrentProcess();
_lastCpuTime = _proc.TotalProcessorTime;
_lastCpuSample = DateTime.UtcNow;
_samplerThread = new Thread(SamplerLoop)
{
IsBackground = true,
Name = "DiagnosticsFlow.Sampler",
Priority = ThreadPriority.BelowNormal
};
_samplerThread.Start();
}
/// <summary>Gets or creates a named counter.</summary>
public Counter GetCounter(string name)
{
if (name == null)
{
throw new ArgumentNullException("name");
}
return _counters.GetOrAdd(name, n => new Counter(n));
}
/// <summary>Current value of a named counter (0 if not yet created).</summary>
public long ReadCounter(string name)
{
Counter c;
return _counters.TryGetValue(name, out c) ? c.Value : 0;
}
public override Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_injectIntoEvents)
{
DiagnosticsSnapshot snap = _latest;
if (snap != null)
{
logEvent.Properties.TryAdd("diag.mem_mb",(snap.WorkingSetBytes / 1024 / 1024).ToString());
logEvent.Properties.TryAdd("diag.cpu",snap.CpuPercent.ToString("F1"));
logEvent.Properties.TryAdd("diag.threads",snap.ThreadCount.ToString());
}
}
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
public override Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
foreach (LogEvent e in logEvents.ToArray())
{
if (IsLogLevelEnabled(e))
{
BlastAsync(e, cancellationToken);
}
}
return Task.FromResult(WriteResult.Success);
}
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
=> Task.FromResult(0);
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_cts.Cancel();
_samplerThread.Join(TimeSpan.FromSeconds(3));
_cts.Dispose();
await base.DisposeAsync().ConfigureAwait(false);
}
private void SamplerLoop()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
Thread.Sleep(_snapshotInterval);
DiagnosticsSnapshot snap = Capture();
_latest = snap;
if (_writeSnapshotEvents && _forwardTo != null)
{
LogEvent ev = BuildSnapshotEvent(snap);
_forwardTo.BlastAsync(ev).GetAwaiter().GetResult();
}
}
catch (ThreadInterruptedException) { break; }
catch (Exception ex)
{
Console.Error.WriteLine("[DiagnosticsFlow] Sampler error: " + ex.Message);
}
}
}
private DiagnosticsSnapshot Capture()
{
_proc.Refresh();
DateTime now = DateTime.UtcNow;
TimeSpan cpuNow = _proc.TotalProcessorTime;
double elapsed = (now - _lastCpuSample).TotalSeconds;
double cpu = elapsed > 0
? (cpuNow - _lastCpuTime).TotalSeconds / elapsed / Environment.ProcessorCount * 100.0
: 0;
_lastCpuTime = cpuNow;
_lastCpuSample = now;
Dictionary<string, object> custom = null;
if (_customMetricsFactory != null)
{
try { custom = _customMetricsFactory(); } catch { }
}
// Append counters to custom dict
if (_counters.Count > 0)
{
if (custom == null)
{
custom = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}
foreach (KeyValuePair<string, Counter> kv in _counters)
{
custom["counter." + kv.Key] = kv.Value.Value;
}
}
return new DiagnosticsSnapshot
{
CapturedAt = now,
CpuPercent = Math.Round(cpu, 2),
WorkingSetBytes = _proc.WorkingSet64,
GcGen0 = GC.CollectionCount(0),
GcGen1 = GC.CollectionCount(1),
GcGen2 = GC.CollectionCount(2),
ThreadCount = _proc.Threads.Count,
HandleCount = _proc.HandleCount,
UptimeSeconds = _uptime.Elapsed.TotalSeconds,
Custom = custom
};
}
private LogEvent BuildSnapshotEvent(DiagnosticsSnapshot snap)
{
var sb = new StringBuilder(256);
sb.AppendFormat(
"Diagnostics | CPU={0:F1}% Mem={1}MB GC=[{2},{3},{4}] Threads={5} Handles={6} Uptime={7:F0}s",
snap.CpuPercent,
snap.WorkingSetBytes / 1024 / 1024,
snap.GcGen0, snap.GcGen1, snap.GcGen2,
snap.ThreadCount,
snap.HandleCount,
snap.UptimeSeconds);
var ev = new LogEvent
{
Level = LogLevel.Information,
Category = _snapshotCategory,
Message = new StringSegment(sb.ToString()),
Timestamp = snap.CapturedAt.Ticks
};
ev.Properties.TryAdd("cpu_pct", snap.CpuPercent.ToString("F2"));
ev.Properties.TryAdd("mem_bytes", snap.WorkingSetBytes.ToString());
ev.Properties.TryAdd("gc_gen0", snap.GcGen0.ToString());
ev.Properties.TryAdd("gc_gen1", snap.GcGen1.ToString());
ev.Properties.TryAdd("gc_gen2", snap.GcGen2.ToString());
ev.Properties.TryAdd("threads", snap.ThreadCount.ToString());
ev.Properties.TryAdd("handles", snap.HandleCount.ToString());
ev.Properties.TryAdd("uptime_s", snap.UptimeSeconds.ToString("F0"));
if (snap.Custom != null)
{
foreach (KeyValuePair<string, object> kv in snap.Custom)
{
ev.Properties.TryAdd(kv.Key, kv.Value != null ? kv.Value.ToString() : "null");
}
}
return ev;
}
}
}
@@ -0,0 +1,199 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// logging flow that sends messages to a Discord channel via webhook.
/// </summary>
public sealed class DiscordFlow : FlowBase, IAsyncDisposable
{
private const int ChannelCapacity = 4096;
private readonly int _batchSize;
private readonly Channel<LogEvent> _channel;
private readonly Task _workerTask;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
public DiscordFlow(
string webhookUrl,
string botName,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Information)
: base("Discord", minimumLevel)
{
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
_httpClient = new HttpClient();
_batchSize = batchSize <= 0 ? 1 : batchSize;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_workerTask = Task.Run(() => ProcessQueueAsync(botName, _cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
private async Task ProcessQueueAsync(string botName, CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
try
{
while (await _channel.Reader.WaitToReadAsync(cancellationToken))
{
while (_channel.Reader.TryRead(out var logEvent))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize)
{
await SendBatchAsync(botName, batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(botName, batch, cancellationToken);
batch.Clear();
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"DiscordFlow error: {ex.Message}");
}
}
private async Task SendBatchAsync(string botName, List<LogEvent> batch, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
var content = new
{
username = botName,
embeds = new[]
{
new
{
title = logEvent.Level.ToString(),
description = logEvent.Message,
color = GetDiscordColor(logEvent.Level),
timestamp = LogEvent.GetDateTime(logEvent.Timestamp).ToString("O"),
fields = logEvent.Properties.Count > 0
? GetFields(logEvent)
: Array.Empty<object>()
}
}
};
var json = JsonHelper.ToJson(content);
using var stringContent = new StringContent(json, Encoding.UTF8, "application/json");
await _httpClient.PostAsync(_webhookUrl, stringContent, cancellationToken);
if (logEvent.Exception != null)
{
var exContent = new
{
username = botName,
content = $"**Exception:** {logEvent.Exception.GetType().FullName}\n```{logEvent.Exception.Message}\n{logEvent.Exception.StackTrace}```"
};
var exJson = JsonHelper.ToJson(exContent);
using var exStringContent = new StringContent(exJson, Encoding.UTF8, "application/json");
await _httpClient.PostAsync(_webhookUrl, exStringContent, cancellationToken);
}
}
}
private static int GetDiscordColor(LogLevel level)
{
return level switch
{
LogLevel.Trace => 0x00FFFF, // Cyan
LogLevel.Debug => 0x00FF00, // Green
LogLevel.Information => 0xFFFFFF, // White
LogLevel.Warning => 0xFFFF00, // Yellow
LogLevel.Error => 0xFF0000, // Red
LogLevel.Critical => 0x800000, // Dark Red
_ => 0x808080, // Gray
};
}
private static object[] GetFields(LogEvent logEvent)
{
var fields = new List<object>();
foreach (var prop in logEvent.Properties)
{
fields.Add(new
{
name = prop.Key,
value = prop.Value?.ToString() ?? "null",
inline = true
});
}
return fields.ToArray();
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
_httpClient.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,190 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Elasticsearch logging flow using HTTP bulk API (without NEST)
/// </summary>
public sealed class ElasticSearchFlow : FlowBase, IAsyncDisposable
{
private const int ChannelCapacity = 4096;
private readonly int _batchSize;
private readonly Channel<LogEvent> _channel;
private readonly Task _workerTask;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _httpClient;
private readonly string _elasticsearchUrl;
private readonly string _indexName;
public ElasticSearchFlow(
string elasticsearchUrl,
string indexName = "logs",
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace)
: base($"Elasticsearch:{indexName}", minimumLevel)
{
_elasticsearchUrl = elasticsearchUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(elasticsearchUrl));
_indexName = indexName;
_httpClient = new HttpClient();
_batchSize = batchSize <= 0 ? 1 : batchSize;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_workerTask = Task.Run(() => ProcessQueueAsync(_cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
try
{
while (await _channel.Reader.WaitToReadAsync(cancellationToken))
{
while (_channel.Reader.TryRead(out var logEvent))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize)
{
await SendBulkAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBulkAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBulkAsync(batch, cancellationToken);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"ElasticSearchFlow error: {ex.Message}");
}
}
private async Task SendBulkAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
foreach (var logEvent in batch)
{
// Action metadata
sb.AppendLine(JsonHelper.ToJson(new { index = new { _index = _indexName } }));
// Document
var doc = new Dictionary<string, object?>
{
["timestamp"] = LogEvent.GetDateTime(logEvent.Timestamp).ToString("O"),
["level"] = logEvent.Level.ToString(),
["category"] = logEvent.Category ?? string.Empty,
["message"] = logEvent.Message.ToString(),
["threadId"] = logEvent.ThreadId
};
if (logEvent.Exception != null)
{
doc["exception"] = new
{
type = logEvent.Exception.GetType().FullName,
message = logEvent.Exception.Message,
stackTrace = logEvent.Exception.StackTrace
};
}
if (logEvent.Properties.Count > 0)
{
var props = new Dictionary<string, object?>();
foreach (var prop in logEvent.Properties)
{
props[prop.Key] = prop.Value;
}
doc["properties"] = props;
}
sb.AppendLine(JsonHelper.ToJson(doc));
}
var content = new StringContent(sb.ToString(), Encoding.UTF8, "application/x-ndjson");
using var response = await _httpClient.PostAsync($"{_elasticsearchUrl}/_bulk", content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var respText = await response.Content.ReadAsStringAsync();
Console.Error.WriteLine($"ElasticSearchFlow bulk insert failed: {response.StatusCode} {respText}");
}
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
_httpClient.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,331 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Mail;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Sends log events as email via SMTP.
///
/// Includes built-in digest batching: instead of one email per event, events are
/// accumulated for up to <see cref="DigestInterval"/> and sent as a single digest.
/// A "flush-on-critical" option bypasses batching for Critical events.
/// </summary>
public sealed class EmailFlow : FlowBase
{
private readonly string _headerName = "<h2>EonaCat Logger Log Digest</h2>";
private readonly string _smtpHost;
private readonly int _smtpPort;
private readonly bool _useSsl;
private readonly string _username;
private readonly string _password;
private readonly string _from;
private readonly string[] _to;
private readonly string _subjectPrefix;
private readonly TimeSpan _digestInterval;
private readonly bool _flushOnCritical;
private readonly int _maxEventsPerDigest;
private readonly List<LogEvent> _pending = new List<LogEvent>();
private readonly object _lock = new object();
private DateTime _lastSent = DateTime.UtcNow;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly Thread _digestThread;
private long _totalEmails;
public EmailFlow(
string smtpHost,
int smtpPort = 587,
bool useSsl = true,
string username = null,
string password = null,
string from = null,
string to = null,
string subjectPrefix = "[EonaCatLogStack]",
int digestMinutes = 5,
bool flushOnCritical = true,
int maxEventsPerDigest = 100,
string headerName = null,
LogLevel minimumLevel = LogLevel.Error)
: base("Email:" + smtpHost, minimumLevel)
{
if (smtpHost == null)
{
throw new ArgumentNullException("smtpHost");
}
if (to == null)
{
throw new ArgumentNullException("to");
}
if (string.IsNullOrWhiteSpace(headerName))
{
_headerName = headerName;
}
_smtpHost = smtpHost;
_smtpPort = smtpPort;
_useSsl = useSsl;
_username = username;
_password = password;
_from = from ?? ("eonacat-logger@" + smtpHost);
_to = to.Split(new char[] { ',', ';' },
StringSplitOptions.RemoveEmptyEntries);
_subjectPrefix = subjectPrefix ?? "[EonaCatLogStack]";
_digestInterval = TimeSpan.FromMinutes(digestMinutes < 1 ? 1 : digestMinutes);
_flushOnCritical = flushOnCritical;
_maxEventsPerDigest = maxEventsPerDigest < 1 ? 1 : maxEventsPerDigest;
_digestThread = new Thread(DigestLoop)
{
IsBackground = true,
Name = "EmailFlow.Digest",
Priority = ThreadPriority.BelowNormal
};
_digestThread.Start();
}
public long TotalEmailsSent { get { return Interlocked.Read(ref _totalEmails); } }
public override Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
bool sendNow = false;
lock (_lock)
{
_pending.Add(logEvent);
if (_flushOnCritical && logEvent.Level >= LogLevel.Critical)
{
sendNow = true;
}
if (_pending.Count >= _maxEventsPerDigest)
{
sendNow = true;
}
}
if (sendNow)
{
SendDigestAsync();
}
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
public override Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
foreach (LogEvent e in logEvents.ToArray())
{
if (IsLogLevelEnabled(e))
{
BlastAsync(e, cancellationToken);
}
}
return Task.FromResult(WriteResult.Success);
}
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
{
SendDigestAsync();
return Task.FromResult(0);
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_cts.Cancel();
_digestThread.Join(TimeSpan.FromSeconds(5));
SendDigestAsync();
_cts.Dispose();
await base.DisposeAsync().ConfigureAwait(false);
}
private void DigestLoop()
{
while (!_cts.Token.IsCancellationRequested)
{
try { Thread.Sleep(TimeSpan.FromSeconds(30)); }
catch (ThreadInterruptedException) { break; }
lock (_lock)
{
if (_pending.Count > 0 && DateTime.UtcNow - _lastSent >= _digestInterval)
{
SendDigestAsync();
}
}
}
}
private void SendDigestAsync()
{
List<LogEvent> batch;
lock (_lock)
{
if (_pending.Count == 0)
{
return;
}
batch = new List<LogEvent>(_pending);
_pending.Clear();
_lastSent = DateTime.UtcNow;
}
// Fire-and-forget on a thread pool thread
ThreadPool.QueueUserWorkItem(_ => SendDigest(batch));
}
private void SendDigest(List<LogEvent> events)
{
try
{
string subject = BuildSubject(events);
string body = BuildBody(events);
using (SmtpClient smtp = new SmtpClient(_smtpHost, _smtpPort))
{
smtp.EnableSsl = _useSsl;
if (!string.IsNullOrEmpty(_username))
{
smtp.Credentials = new NetworkCredential(_username, _password);
}
using (MailMessage msg = new MailMessage())
{
msg.From = new MailAddress(_from);
msg.Subject = subject;
msg.Body = body;
msg.IsBodyHtml = true;
foreach (string addr in _to)
{
msg.To.Add(addr.Trim());
}
smtp.Send(msg);
}
}
Interlocked.Increment(ref _totalEmails);
}
catch (Exception ex)
{
Console.Error.WriteLine("[EmailFlow] Send error: " + ex.Message);
Interlocked.Increment(ref DroppedCount);
}
}
private string BuildSubject(List<LogEvent> events)
{
LogLevel maxLevel = LogLevel.Trace;
foreach (LogEvent e in events)
{
if (e.Level > maxLevel)
{
maxLevel = e.Level;
}
}
return _subjectPrefix + " " + LevelString(maxLevel) +
" " + events.Count + " event(s) @ " +
DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") + " UTC";
}
private string BuildBody(List<LogEvent> events)
{
var sb = new StringBuilder(events.Count * 300);
sb.AppendLine("<html><body style='font-family:monospace;font-size:13px'>");
sb.AppendLine(_headerName);
sb.AppendLine("<table border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse;width:100%'>");
sb.AppendLine("<tr style='background:#333;color:white'>" +
"<th>Time</th><th>Level</th><th>Category</th>" +
"<th>Message</th><th>Exception</th></tr>");
foreach (LogEvent e in events)
{
string color = LevelColor(e.Level);
string ts = LogEvent.GetDateTime(e.Timestamp).ToString("HH:mm:ss.fff");
string msg = HtmlEncode(e.Message.Length > 0 ? e.Message.ToString() : string.Empty);
string exc = e.Exception != null
? HtmlEncode(e.Exception.GetType().Name + ": " + e.Exception.Message)
: string.Empty;
sb.AppendFormat(
"<tr style='background:{0}'><td>{1}</td><td><b>{2}</b></td><td>{3}</td><td>{4}</td><td>{5}</td></tr>",
color, ts, LevelString(e.Level),
HtmlEncode(e.Category ?? string.Empty), msg, exc);
sb.AppendLine();
}
sb.AppendLine("</table></body></html>");
return sb.ToString();
}
private static string LevelColor(LogLevel level)
{
switch (level)
{
case LogLevel.Warning: return "#FFF3CD";
case LogLevel.Error: return "#F8D7DA";
case LogLevel.Critical: return "#F1AEB5";
default: return "#FFFFFF";
}
}
private static string LevelString(LogLevel level)
{
switch (level)
{
case LogLevel.Trace: return "TRACE";
case LogLevel.Debug: return "DEBUG";
case LogLevel.Information: return "INFO";
case LogLevel.Warning: return "WARN";
case LogLevel.Error: return "ERROR";
case LogLevel.Critical: return "CRITICAL";
default: return level.ToString().ToUpperInvariant();
}
}
private static string HtmlEncode(string s)
{
if (string.IsNullOrEmpty(s))
{
return string.Empty;
}
return s.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;");
}
}
}
@@ -0,0 +1,499 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Writes log events to AES-256-CBC encrypted, append-only binary files.
///
/// File layout:
/// [4 bytes magic "EONA"] [32 bytes salt] [16 bytes IV]
/// repeated: [4 bytes LE block-length] [N bytes ciphertext]
///
/// Key derivation: PBKDF2-HMACSHA1 with 100 000 iterations.
/// Each individual line is encrypted independently (ECB-safe CBC block) so the
/// file can be read entry-by-entry via <see cref="DecryptToFile"/>.
///
/// </summary>
public sealed class EncryptedFileFlow : FlowBase
{
private static readonly byte[] Magic = new byte[] { 0x45, 0x4F, 0x4E, 0x41 }; // "EONA"
private const int SaltSize = 32;
private const int IvSize = 16;
private const int KeySize = 32; // AES-256
private const int Pbkdf2Iter = 100000;
private readonly BlockingCollection<string> _queue;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly Thread _writerThread;
private readonly Thread _flushThread;
private readonly string _directory;
private readonly string _filePrefix;
private readonly string _password;
private readonly long _maxFileSize;
private readonly int _flushIntervalMs;
private readonly TimestampMode _timestampMode;
private readonly object _lock = new object();
private FileStream _currentStream;
private ICryptoTransform _encryptor;
private string _currentPath;
private long _currentSize;
private DateTime _currentDate;
private long _totalWritten;
private long _totalRotations;
public EncryptedFileFlow(
string directory,
string password,
string filePrefix = "encrypted_log",
long maxFileSize = 50L * 1024 * 1024,
int flushIntervalMs = 3000,
LogLevel minimumLevel = LogLevel.Trace,
TimestampMode tsMode = TimestampMode.Utc)
: base("EncryptedFile:" + directory, minimumLevel)
{
if (directory == null)
{
throw new ArgumentNullException("directory");
}
if (password == null)
{
throw new ArgumentNullException("password");
}
if (filePrefix == null)
{
throw new ArgumentNullException("filePrefix");
}
_directory = directory;
_password = password;
_filePrefix = filePrefix;
_maxFileSize = maxFileSize;
_flushIntervalMs = flushIntervalMs;
_timestampMode = tsMode;
// Resolve relative path
if (_directory.StartsWith("./", StringComparison.Ordinal))
{
_directory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, _directory.Substring(2));
}
Directory.CreateDirectory(_directory);
_queue = new BlockingCollection<string>(new ConcurrentQueue<string>(), 8192);
_writerThread = new Thread(WriterLoop)
{
IsBackground = true,
Name = "EncryptedFileFlow.Writer",
Priority = ThreadPriority.AboveNormal
};
_writerThread.Start();
_flushThread = new Thread(FlushLoop)
{
IsBackground = true,
Name = "EncryptedFileFlow.Flush",
Priority = ThreadPriority.BelowNormal
};
_flushThread.Start();
}
/// <summary>
/// Decrypts an .eona file produced by this flow to a plain-text file.
/// </summary>
public static bool DecryptToFile(string encryptedPath, string outputPath, string password)
{
if (encryptedPath == null)
{
throw new ArgumentNullException("encryptedPath");
}
if (outputPath == null)
{
throw new ArgumentNullException("outputPath");
}
if (password == null)
{
throw new ArgumentNullException("password");
}
try
{
using (FileStream source = File.OpenRead(encryptedPath))
{
byte[] magic = new byte[4];
ReadExact(source, magic, 4);
for (int i = 0; i < 4; i++)
{
if (magic[i] != Magic[i])
{
throw new InvalidDataException("Not a valid EONA encrypted log file.");
}
}
byte[] salt = new byte[SaltSize];
byte[] iv = new byte[IvSize];
ReadExact(source, salt, SaltSize);
ReadExact(source, iv, IvSize);
byte[] key = DeriveKey(password, salt);
using (Aes aes = Aes.Create())
{
aes.KeySize = 256;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = key;
aes.IV = iv;
using (ICryptoTransform dec = aes.CreateDecryptor())
using (StreamWriter out_ = new StreamWriter(outputPath, false, Encoding.UTF8))
{
byte[] buffer = new byte[4];
while (source.Position < source.Length)
{
int read = source.Read(buffer, 0, 4);
if (read < 4)
{
break;
}
int blockLength = BitConverter.ToInt32(buffer, 0);
if (blockLength <= 0 || blockLength > 16 * 1024 * 1024)
{
throw new InvalidDataException("Corrupt block at offset " + (source.Position - 4));
}
byte[] cipher = new byte[blockLength];
ReadExact(source, cipher, blockLength);
byte[] plain = dec.TransformFinalBlock(cipher, 0, cipher.Length);
out_.WriteLine(Encoding.UTF8.GetString(plain));
return true;
}
}
}
}
}
catch (Exception e)
{
Console.WriteLine($"Exception during decryption => {e.Message}");
}
return false;
}
public LogStats GetStats()
{
return new LogStats(
Interlocked.Read(ref _totalWritten),
Interlocked.Read(ref DroppedCount),
Interlocked.Read(ref _totalRotations), 0, 0);
}
public override Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
return Task.FromResult(TryEnqueue(Format(logEvent)));
}
public override Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
WriteResult result = WriteResult.Success;
foreach (LogEvent e in logEvents.ToArray())
{
if (e.Level < MinimumLevel)
{
continue;
}
if (TryEnqueue(Format(e)) == WriteResult.Dropped)
{
result = WriteResult.Dropped;
}
}
return Task.FromResult(result);
}
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
{
lock (_lock)
{
if (_currentStream != null)
{
_currentStream.Flush(true);
}
}
return Task.FromResult(0);
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_queue.CompleteAdding();
_cts.Cancel();
_writerThread.Join(TimeSpan.FromSeconds(5));
_flushThread.Join(TimeSpan.FromSeconds(2));
lock (_lock) { CloseCurrentFile(); }
_cts.Dispose();
_queue.Dispose();
await base.DisposeAsync().ConfigureAwait(false);
}
private void WriterLoop()
{
try
{
while (!_queue.IsCompleted)
{
string line;
try { line = _queue.Take(_cts.Token); }
catch (OperationCanceledException) { break; }
catch (InvalidOperationException) { break; }
WriteEncrypted(line);
string extra;
int batch = 0;
while (batch < 256 && _queue.TryTake(out extra))
{
WriteEncrypted(extra);
batch++;
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine("[EncryptedFileFlow] Writer error: " + ex.Message);
}
finally
{
string remaining;
while (_queue.TryTake(out remaining))
{
WriteEncrypted(remaining);
}
lock (_lock) { CloseCurrentFile(); }
}
}
private void FlushLoop()
{
while (!_cts.Token.IsCancellationRequested)
{
try { Thread.Sleep(_flushIntervalMs); }
catch (ThreadInterruptedException) { break; }
lock (_lock)
{
if (_currentStream != null)
{
try { _currentStream.Flush(true); } catch { }
}
}
}
}
private void WriteEncrypted(string line)
{
lock (_lock)
{
DateTime today = _timestampMode == TimestampMode.Local
? DateTime.Now.Date
: DateTime.UtcNow.Date;
if (_currentStream == null || _currentDate != today || _currentSize > _maxFileSize)
{
if (_currentStream != null)
{
Interlocked.Increment(ref _totalRotations);
}
CloseCurrentFile();
OpenNewFile(today);
}
byte[] plain = Encoding.UTF8.GetBytes(line);
byte[] cipher = _encryptor.TransformFinalBlock(plain, 0, plain.Length);
byte[] lenBuf = BitConverter.GetBytes(cipher.Length);
_currentStream.Write(lenBuf, 0, 4);
_currentStream.Write(cipher, 0, cipher.Length);
_currentSize += 4 + cipher.Length;
Interlocked.Increment(ref _totalWritten);
Interlocked.Increment(ref BlastedCount);
}
}
private void OpenNewFile(DateTime date)
{
_currentDate = date;
_currentPath = Path.Combine(
_directory,
_filePrefix + "_" + Environment.MachineName + "_" + date.ToString("yyyyMMdd") + ".eona");
bool isNew = !File.Exists(_currentPath) || new FileInfo(_currentPath).Length == 0;
_currentStream = new FileStream(
_currentPath, FileMode.Append, FileAccess.Write, FileShare.Read, 65536);
byte[] salt = new byte[SaltSize];
byte[] iv = new byte[IvSize];
if (isNew)
{
using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider())
{
rng.GetBytes(salt);
rng.GetBytes(iv);
}
_currentStream.Write(Magic, 0, 4);
_currentStream.Write(salt, 0, SaltSize);
_currentStream.Write(iv, 0, IvSize);
_currentSize = 4 + SaltSize + IvSize;
}
else
{
// Re-read header so we continue the same key/IV session
using (FileStream hdr = File.OpenRead(_currentPath))
{
hdr.Seek(4, SeekOrigin.Begin);
ReadExact(hdr, salt, SaltSize);
ReadExact(hdr, iv, IvSize);
}
_currentSize = new FileInfo(_currentPath).Length;
}
byte[] key = DeriveKey(_password, salt);
Aes aes = Aes.Create();
aes.KeySize = 256;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = key;
aes.IV = iv;
_encryptor = aes.CreateEncryptor();
}
private void CloseCurrentFile()
{
if (_encryptor != null)
{
try { _encryptor.Dispose(); } catch { }
_encryptor = null;
}
if (_currentStream != null)
{
try { _currentStream.Flush(true); _currentStream.Dispose(); } catch { }
_currentStream = null;
}
}
private WriteResult TryEnqueue(string line)
{
if (_queue.TryAdd(line))
{
return WriteResult.Success;
}
Interlocked.Increment(ref DroppedCount);
return WriteResult.Dropped;
}
private string Format(LogEvent log)
{
DateTime ts = LogEvent.GetDateTime(log.Timestamp);
var sb = new StringBuilder(256);
sb.Append(ts.ToString("yyyy-MM-dd HH:mm:ss.fff"));
sb.Append(" [").Append(LevelString(log.Level)).Append("] ");
sb.Append(log.Category ?? string.Empty);
sb.Append(": ");
sb.Append(log.Message.Length > 0 ? log.Message.ToString() : string.Empty);
if (log.Exception != null)
{
sb.Append(" | EX: ").Append(log.Exception.GetType().Name)
.Append(": ").Append(log.Exception.Message);
}
if (log.Properties.Count > 0)
{
sb.Append(" |");
foreach (var kv in log.Properties.ToArray())
{
sb.Append(' ').Append(kv.Key).Append('=')
.Append(kv.Value != null ? kv.Value.ToString() : "null");
}
}
return sb.ToString();
}
private static string LevelString(LogLevel level)
{
switch (level)
{
case LogLevel.Trace: return "TRACE";
case LogLevel.Debug: return "DEBUG";
case LogLevel.Information: return "INFO";
case LogLevel.Warning: return "WARN";
case LogLevel.Error: return "ERROR";
case LogLevel.Critical: return "CRITICAL";
default: return level.ToString().ToUpperInvariant();
}
}
private static byte[] DeriveKey(string password, byte[] salt)
{
using (Rfc2898DeriveBytes kdf = new Rfc2898DeriveBytes(password, salt, Pbkdf2Iter))
{
return kdf.GetBytes(KeySize);
}
}
private static void ReadExact(Stream stream, byte[] buf, int count)
{
int offset = 0;
while (offset < count)
{
int r = stream.Read(buf, offset, count - offset);
if (r == 0)
{
throw new EndOfStreamException("Unexpected end of encrypted log stream.");
}
offset += r;
}
}
}
}
@@ -0,0 +1,214 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.IO;
using System.Net.Security;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
namespace EonaCat.LogStack.Flows
{
public sealed class EventLogFlow : FlowBase
{
private readonly string _destination;
private readonly int _port;
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private SslStream? _sslStream;
private readonly bool _useTls;
private readonly RemoteCertificateValidationCallback? _certificateValidationCallback;
private readonly X509CertificateCollection? _clientCertificates;
private readonly List<LogEvent> _logBuffer;
private readonly int _bufferSize;
private readonly TimeSpan _flushInterval;
private readonly CancellationTokenSource _cts;
public EventLogFlow(
string destination,
int port = 514,
LogLevel minimumLevel = LogLevel.Trace,
int bufferSize = 100,
TimeSpan? flushInterval = null,
bool useTls = false,
RemoteCertificateValidationCallback? certificateValidationCallback = null,
X509CertificateCollection? clientCertificates = null
) : base($"EventLogFlow:{destination}:{port}", minimumLevel)
{
_destination = destination ?? throw new ArgumentNullException(nameof(destination));
_port = port;
_useTls = useTls;
_certificateValidationCallback = certificateValidationCallback;
_clientCertificates = clientCertificates;
_bufferSize = bufferSize;
_flushInterval = flushInterval ?? TimeSpan.FromSeconds(5);
_logBuffer = new List<LogEvent>(bufferSize);
_cts = new CancellationTokenSource();
_tcpClient = new TcpClient();
_ = StartFlushingLogsAsync(_cts.Token);
}
public void Log(string message, string category = "CustomEvent", LogLevel level = LogLevel.Information, object customData = null)
{
var logEvent = new LogEvent
{
Timestamp = DateTime.UtcNow.Ticks,
Level = level,
Message = message.ToCharArray(),
Category = category,
CustomData = customData != null ? JsonHelper.ToJson(customData) : string.Empty
};
// Add to buffer and trigger flush if needed
_logBuffer.Add(logEvent);
if (_logBuffer.Count >= _bufferSize)
{
_ = FlushLogsAsync();
}
}
private async Task StartFlushingLogsAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(_flushInterval, cancellationToken);
if (_logBuffer.Count > 0)
{
await FlushLogsAsync();
}
}
}
private async Task FlushLogsAsync()
{
if (_logBuffer.Count == 0)
{
return;
}
var logsToSend = new List<LogEvent>(_logBuffer);
_logBuffer.Clear();
await SendLogsAsync(logsToSend);
}
private async Task SendLogsAsync(IEnumerable<LogEvent> logEvents)
{
try
{
await EnsureConnectedAsync();
var logMessages = new StringBuilder();
foreach (var logEvent in logEvents)
{
logMessages.AppendLine(FormatLogMessage(logEvent));
}
var data = Encoding.UTF8.GetBytes(logMessages.ToString());
if (_useTls && _sslStream != null)
{
await _sslStream.WriteAsync(data, 0, data.Length);
await _sslStream.FlushAsync();
}
else if (_stream != null)
{
await _stream.WriteAsync(data, 0, data.Length);
await _stream.FlushAsync();
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error sending logs: {ex.Message}");
}
}
private async Task EnsureConnectedAsync()
{
if (_tcpClient?.Connected ?? false)
{
return;
}
await _tcpClient?.ConnectAsync(_destination, _port);
if (_useTls)
{
_stream = _tcpClient?.GetStream();
_sslStream = new SslStream(_stream, false, _certificateValidationCallback);
await _sslStream.AuthenticateAsClientAsync(_destination, _clientCertificates, System.Security.Authentication.SslProtocols.Tls12, false);
}
else
{
_stream = _tcpClient?.GetStream();
}
}
private string FormatLogMessage(LogEvent logEvent)
{
var dt = LogEvent.GetDateTime(logEvent.Timestamp);
var sb = new StringBuilder();
sb.Append(dt.ToString("yyyy-MM-dd HH:mm:ss.fff"));
sb.Append(" [");
sb.Append(logEvent.Level.ToString().ToUpperInvariant());
sb.Append("] ");
sb.Append(logEvent.Category);
sb.Append(": ");
sb.Append(logEvent.Message);
if (!string.IsNullOrEmpty(logEvent.CustomData))
{
sb.Append(" | CustomData: ");
sb.Append(logEvent.CustomData);
}
return sb.ToString();
}
public override async Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return WriteResult.LevelFiltered;
}
await SendLogsAsync(new List<LogEvent> { logEvent });
return WriteResult.Success;
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.LevelFiltered;
}
await SendLogsAsync(logEvents.Span.ToArray());
return WriteResult.Success;
}
public override async ValueTask DisposeAsync()
{
_cts.Cancel();
_sslStream?.Dispose();
_stream?.Dispose();
_tcpClient?.Dispose();
await base.DisposeAsync();
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,50 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.Flows;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class FailoverFlow : FlowBase
{
private readonly IFlow _primary;
private readonly IFlow _secondary;
public FailoverFlow(IFlow primary, IFlow secondary)
: base($"Failover({primary.Name})", primary.MinimumLevel)
{
_primary = primary ?? throw new ArgumentNullException(nameof(primary));
_secondary = secondary ?? throw new ArgumentNullException(nameof(secondary));
}
public override async Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
var result = await _primary.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false);
if (result == WriteResult.Success)
{
return result;
}
return await _secondary.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false);
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
await _primary.FlushAsync(cancellationToken);
await _secondary.FlushAsync(cancellationToken);
}
public override async ValueTask DisposeAsync()
{
await _primary.DisposeAsync();
await _secondary.DisposeAsync();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class GraylogFlow : FlowBase
{
private readonly int _batchSize;
private const int ChannelCapacity = 4096;
private const int MaxUdpPacketSize = 8192;
private readonly Channel<LogEvent> _channel;
private readonly Task _senderTask;
private readonly CancellationTokenSource _cts;
private readonly string _host;
private readonly int _port;
private readonly bool _useTcp;
private TcpClient? _tcpClient;
private NetworkStream? _tcpStream;
private UdpClient? _udpClient;
private readonly BackpressureStrategy _backpressureStrategy;
private readonly string _graylogHostName;
public GraylogFlow(
string host,
int port = 12201,
bool useTcp = false,
string graylogHostName = null,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace,
BackpressureStrategy backpressureStrategy = BackpressureStrategy.DropOldest)
: base($"Graylog:{host}:{port}", minimumLevel)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_port = port;
_useTcp = useTcp;
_backpressureStrategy = backpressureStrategy;
_graylogHostName = graylogHostName ?? Environment.MachineName;
_batchSize = batchSize <= 0 ? 1 : batchSize;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = backpressureStrategy switch
{
BackpressureStrategy.Wait => BoundedChannelFullMode.Wait,
BackpressureStrategy.DropNewest => BoundedChannelFullMode.DropWrite,
BackpressureStrategy.DropOldest => BoundedChannelFullMode.DropOldest,
_ => BoundedChannelFullMode.Wait
},
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
if (!_useTcp)
{
_udpClient = new UdpClient();
}
_senderTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
var result = WriteResult.Success;
foreach (var logEvent in logEvents.Span)
{
if (!IsLogLevelEnabled(logEvent))
{
continue;
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
}
else
{
Interlocked.Increment(ref DroppedCount);
result = WriteResult.Dropped;
}
}
return result;
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
while (!cancellationToken.IsCancellationRequested)
{
try
{
if (_useTcp)
{
await EnsureTcpConnectedAsync(cancellationToken);
}
await foreach (var logEvent in _channel.Reader.ReadAllAsync(cancellationToken))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize || _channel.Reader.Count == 0)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"GraylogFlow error: {ex.Message}");
await Task.Delay(1000, cancellationToken);
_tcpClient?.Dispose();
_tcpClient = null;
}
}
}
private async Task EnsureTcpConnectedAsync(CancellationToken cancellationToken)
{
if (_tcpClient != null && _tcpClient.Connected)
{
return;
}
_tcpClient?.Dispose();
_tcpClient = new TcpClient();
await _tcpClient.ConnectAsync(_host, _port);
_tcpStream = _tcpClient.GetStream();
}
private static double ToUnixTimeSeconds(DateTime dt)
{
// Make sure the DateTime is UTC
var utc = dt.ToUniversalTime();
var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
return (utc - epoch).TotalSeconds;
}
private async Task SendBatchAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
var dt = LogEvent.GetDateTime(logEvent.Timestamp);
var unixTimestamp = ToUnixTimeSeconds(dt);
var gelfMessage = new
{
version = "1.1",
host = _graylogHostName,
short_message = logEvent.Message,
timestamp = unixTimestamp,
level = MapLogLevelToSyslogSeverity(logEvent.Level),
_category = logEvent.Category
};
string json = JsonHelper.ToJson(gelfMessage);
byte[] data = Encoding.UTF8.GetBytes(json);
if (_useTcp)
{
if (_tcpStream != null)
{
await _tcpStream.WriteAsync(data, 0, data.Length, cancellationToken);
await _tcpStream.FlushAsync(cancellationToken);
}
}
else
{
if (_udpClient != null)
{
if (data.Length <= MaxUdpPacketSize)
{
await _udpClient.SendAsync(data, data.Length, _host, _port);
}
else
{
await SendUdpInChunksAsync(data, MaxUdpPacketSize, cancellationToken);
}
}
}
}
}
private async Task SendUdpInChunksAsync(byte[] data, int chunkSize, CancellationToken cancellationToken)
{
int offset = 0;
byte[] buffer = ArrayPool<byte>.Shared.Rent(chunkSize);
try
{
while (offset < data.Length)
{
int size = Math.Min(chunkSize, data.Length - offset);
Buffer.BlockCopy(data, offset, buffer, 0, size);
if (_udpClient != null)
{
await _udpClient.SendAsync(buffer, size, _host, _port);
}
offset += size;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private int MapLogLevelToSyslogSeverity(LogLevel level)
{
return level switch
{
LogLevel.Trace => 7,
LogLevel.Debug => 7,
LogLevel.Information => 6,
LogLevel.Warning => 4,
LogLevel.Error => 3,
LogLevel.Critical => 2,
_ => 6
};
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try { await _senderTask.ConfigureAwait(false); } catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try { await _senderTask.ConfigureAwait(false); } catch { }
_tcpStream?.Dispose();
_tcpClient?.Dispose();
_udpClient?.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,285 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows;
/// <summary>
/// HTTP flow for sending logs to remote endpoints with batching and retry logic
/// </summary>
public sealed class HttpFlow : FlowBase
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
private const int ChannelCapacity = 2048;
private readonly int _batchSize;
private const int MaxRetries = 3;
private readonly Channel<LogEvent> _channel;
private readonly Task _writerTask;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _httpClient;
private readonly string _endpoint;
private readonly bool _ownHttpClient;
private readonly TimeSpan _batchInterval;
private readonly Dictionary<string, string>? _headers;
public HttpFlow(
string endpoint,
HttpClient? httpClient = null,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Information,
TimeSpan? batchInterval = null,
Dictionary<string, string>? headers = null)
: base($"Http:{endpoint}", minimumLevel)
{
_endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
_batchInterval = batchInterval ?? TimeSpan.FromSeconds(5);
_headers = headers;
if (httpClient == null)
{
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
};
_ownHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownHttpClient = false;
}
_batchSize = batchSize <= 0 ? 1 : batchSize;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_writerTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try
{
await _writerTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected
}
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
try
{
while (!cancellationToken.IsCancellationRequested)
{
var hasMore = true;
// Collect batch
while (batch.Count < _batchSize && hasMore)
{
if (_channel.Reader.TryRead(out var logEvent))
{
batch.Add(logEvent);
}
else
{
hasMore = false;
}
}
// Send batch if we have events
if (batch.Count > 0)
{
await SendBatchWithRetryAsync(batch, cancellationToken).ConfigureAwait(false);
batch.Clear();
}
// Wait for either new events or batch interval
if (_channel.Reader.Count == 0)
{
try
{
await Task.Delay(_batchInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected if cancellation was requested during delay
break;
}
}
}
}
catch (OperationCanceledException)
{
// Expected when shutting down
}
catch (Exception ex)
{
Console.Error.WriteLine($"HttpFlow error: {ex.Message}");
}
}
private async Task SendBatchWithRetryAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
var payload = SerializeBatch(batch);
// Serialize payload to JSON string
var jsonPayload = JsonHelper.ToJson(payload);
for (int retry = 0; retry < MaxRetries; retry++)
{
try
{
using (var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"))
using (var request = new HttpRequestMessage(HttpMethod.Post, _endpoint) { Content = content })
{
if (_headers != null)
{
foreach (var header in _headers)
{
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return; // Success
}
// Last retry: mark as dropped
if (retry == MaxRetries - 1)
{
Interlocked.Add(ref DroppedCount, batch.Count);
}
}
}
catch
{
if (retry == MaxRetries - 1)
{
Interlocked.Add(ref DroppedCount, batch.Count);
}
}
// Exponential backoff
if (retry < MaxRetries - 1)
{
await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, retry)), cancellationToken).ConfigureAwait(false);
}
}
}
private object[] SerializeBatch(List<LogEvent> batch)
{
var payload = new object[batch.Count];
for (int i = 0; i < batch.Count; i++)
{
var logEvent = batch[i];
var dto = new Dictionary<string, object?>
{
["timestamp"] = LogEvent.GetDateTime(logEvent.Timestamp).ToString("O"),
["level"] = logEvent.Level.ToString(),
["message"] = logEvent.Message.ToString(),
["category"] = logEvent.Category,
["threadId"] = logEvent.ThreadId
};
if (logEvent.TraceId != default)
{
dto["traceId"] = logEvent.TraceId.ToString();
}
if (logEvent.SpanId != default)
{
dto["spanId"] = logEvent.SpanId.ToString();
}
if (logEvent.Exception != null)
{
dto["exception"] = new
{
type = logEvent.Exception.GetType().FullName,
message = logEvent.Exception.Message,
stackTrace = logEvent.Exception.StackTrace
};
}
if (logEvent.Properties.Count > 0)
{
var props = new Dictionary<string, object?>();
foreach (var prop in logEvent.Properties)
{
props[prop.Key] = prop.Value;
}
dto["properties"] = props;
}
payload[i] = dto;
}
return payload;
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try
{
await _writerTask.ConfigureAwait(false);
}
catch { }
if (_ownHttpClient)
{
_httpClient.Dispose();
}
_cts.Dispose();
await base.DisposeAsync();
}
}
@@ -0,0 +1,180 @@
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// In-memory flow with circular buffer for diagnostics and testing.
/// Designed for high-speed logging with bounded memory usage.
/// </summary>
public sealed class MemoryFlow : FlowBase
{
private readonly LogEvent[] _buffer;
private readonly int _capacity;
private int _head;
private int _tail;
private int _count;
private readonly object _lock = new();
public MemoryFlow(
int capacity = 10000,
LogLevel minimumLevel = LogLevel.Trace)
: base("Memory", minimumLevel)
{
if (capacity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_capacity = capacity;
_buffer = new LogEvent[capacity];
_head = 0;
_tail = 0;
_count = 0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
lock (_lock)
{
_buffer[_tail] = logEvent;
_tail = (_tail + 1) % _capacity;
if (_count == _capacity)
{
// Buffer is full, overwrite oldest
_head = (_head + 1) % _capacity;
Interlocked.Increment(ref DroppedCount);
}
else
{
_count++;
}
}
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
{
// No-op for memory flow
return Task.CompletedTask;
}
/// <summary>
/// Gets all log events currently in the buffer
/// </summary>
public LogEvent[] GetEvents()
{
lock (_lock)
{
var events = new LogEvent[_count];
for (int i = 0; i < _count; i++)
{
var index = (_head + i) % _capacity;
events[i] = _buffer[index];
}
return events;
}
}
/// <summary>
/// Gets events matching the specified level
/// </summary>
public LogEvent[] GetEvents(LogLevel level)
{
lock (_lock)
{
var matching = new List<LogEvent>(_count);
for (int i = 0; i < _count; i++)
{
var index = (_head + i) % _capacity;
if (_buffer[index].Level == level)
{
matching.Add(_buffer[index]);
}
}
return matching.ToArray();
}
}
/// <summary>
/// Gets the most recent N events
/// </summary>
public LogEvent[] GetRecentEvents(int count)
{
lock (_lock)
{
var actualCount = Math.Min(count, _count);
var events = new LogEvent[actualCount];
for (int i = 0; i < actualCount; i++)
{
var index = (_tail - actualCount + i + _capacity) % _capacity;
events[i] = _buffer[index];
}
return events;
}
}
/// <summary>
/// Clears all events from the buffer
/// </summary>
public void Clear()
{
lock (_lock)
{
Array.Clear(_buffer, 0, _buffer.Length);
_head = 0;
_tail = 0;
_count = 0;
}
}
/// <summary>
/// Gets the current count of events in the buffer
/// </summary>
public int Count
{
get
{
lock (_lock)
{
return _count;
}
}
}
/// <summary>
/// Gets whether the buffer is full
/// </summary>
public bool IsFull
{
get
{
lock (_lock)
{
return _count == _capacity;
}
}
}
}
@@ -0,0 +1,180 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// logging flow that sends messages to Microsoft Teams via an incoming webhook.
/// </summary>
public sealed class MicrosoftTeamsFlow : FlowBase, IAsyncDisposable
{
private const int ChannelCapacity = 4096;
private readonly int _batchSize;
private readonly Channel<LogEvent> _channel;
private readonly Task _workerTask;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
public MicrosoftTeamsFlow(
string webhookUrl,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Information)
: base("MicrosoftTeams", minimumLevel)
{
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
_httpClient = new HttpClient();
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_batchSize = batchSize <= 0 ? 1 : batchSize;
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_workerTask = Task.Run(() => ProcessQueueAsync(_cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
try
{
while (await _channel.Reader.WaitToReadAsync(cancellationToken))
{
while (_channel.Reader.TryRead(out var logEvent))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(batch, cancellationToken);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"MicrosoftTeamsFlow error: {ex.Message}");
}
}
private async Task SendBatchAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
var payload = new
{
// Teams expects a "text" field with Markdown or simple message
text = BuildMessage(logEvent)
};
var json = JsonHelper.ToJson(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await _httpClient.PostAsync(_webhookUrl, content, cancellationToken);
}
}
private static string BuildMessage(LogEvent logEvent)
{
var sb = new StringBuilder();
sb.AppendLine($"**Level:** {logEvent.Level}");
if (!string.IsNullOrWhiteSpace(logEvent.Category))
{
sb.AppendLine($"**Category:** {logEvent.Category}");
}
sb.AppendLine($"**Timestamp:** {LogEvent.GetDateTime(logEvent.Timestamp):yyyy-MM-dd HH:mm:ss.fff}");
sb.AppendLine($"**Message:** {logEvent.Message}");
if (logEvent.Exception != null)
{
sb.AppendLine("**Exception:**");
sb.AppendLine($"```\n{logEvent.Exception.GetType().FullName}: {logEvent.Exception.Message}\n{logEvent.Exception.StackTrace}\n```");
}
if (logEvent.Properties.Count > 0)
{
sb.AppendLine("**Properties:**");
foreach (var prop in logEvent.Properties)
{
sb.AppendLine($"`{prop.Key}` = `{prop.Value?.ToString() ?? "null"}`");
}
}
return sb.ToString();
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
_httpClient.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,401 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Publishes log events to a Redis channel using the PUBLISH command (Pub/Sub)
/// and optionally appends them to a Redis List (LPUSH) for persistence.
///
/// Uses raw TCP + RESP protocol, so there arent additional dependencies
///
/// Features:
/// - Reconnect with exponential back-off on connection failure
/// - Optional LPUSH to a list key with LTRIM to cap list length
/// - Optional password authentication (AUTH command)
/// - Optional DB selection (SELECT command)
/// - Background writer thread (non-blocking callers)
/// </summary>
public sealed class RedisFlow : FlowBase
{
private readonly string _host;
private readonly int _port;
private readonly string _password;
private readonly int _database;
private readonly string _channel; // PUBLISH channel (Pub/Sub)
private readonly string _listKey; // LPUSH list key (null = disabled)
private readonly int _maxListLength; // LTRIM cap (0 = unlimited)
private readonly BlockingCollection<string> _queue;
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly Thread _writerThread;
private TcpClient _tcp;
private NetworkStream _stream;
private readonly object _connLock = new object();
private long _totalPublished;
public RedisFlow(
string host = "localhost",
int port = 6379,
string password = null,
int database = 0,
string channel = "eonacat:logs",
string listKey = null,
int maxListLength = 10000,
LogLevel minimumLevel = LogLevel.Trace)
: base("Redis:" + host + ":" + port, minimumLevel)
{
if (host == null)
{
throw new ArgumentNullException("host");
}
if (channel == null)
{
throw new ArgumentNullException("channel");
}
_host = host;
_port = port;
_password = password;
_database = database;
_channel = channel;
_listKey = listKey;
_maxListLength = maxListLength;
_queue = new BlockingCollection<string>(new ConcurrentQueue<string>(), 16384);
_writerThread = new Thread(WriterLoop)
{
IsBackground = true,
Name = "RedisFlow.Writer",
Priority = ThreadPriority.AboveNormal
};
_writerThread.Start();
}
public long TotalPublished { get { return Interlocked.Read(ref _totalPublished); } }
public override Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
string json = Serialize(logEvent);
if (_queue.TryAdd(json))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
WriteResult result = WriteResult.Success;
foreach (LogEvent e in logEvents.ToArray())
{
if (e.Level < MinimumLevel)
{
continue;
}
if (BlastAsync(e, cancellationToken).GetAwaiter().GetResult() == WriteResult.Dropped)
{
result = WriteResult.Dropped;
}
}
return Task.FromResult(result);
}
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
{
Stopwatch sw = System.Diagnostics.Stopwatch.StartNew();
while (_queue.Count > 0 && sw.Elapsed < TimeSpan.FromSeconds(5))
{
Thread.Sleep(5);
}
return Task.FromResult(0);
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_queue.CompleteAdding();
_cts.Cancel();
_writerThread.Join(TimeSpan.FromSeconds(5));
Disconnect();
_cts.Dispose();
_queue.Dispose();
await base.DisposeAsync().ConfigureAwait(false);
}
private void WriterLoop()
{
int backoff = 500;
while (!_queue.IsCompleted)
{
try
{
EnsureConnected();
backoff = 500; // reset on successful connect
while (!_queue.IsCompleted)
{
string msg;
try { msg = _queue.Take(_cts.Token); }
catch (OperationCanceledException) { return; }
catch (InvalidOperationException) { return; }
SendToRedis(msg);
string extra;
int batch = 0;
while (batch < 64 && _queue.TryTake(out extra))
{
SendToRedis(extra);
batch++;
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine("[RedisFlow] Error: " + ex.Message + ". Reconnecting in " + backoff + "ms.");
Disconnect();
try { Thread.Sleep(backoff); } catch (ThreadInterruptedException) { return; }
backoff = Math.Min(backoff * 2, 30000);
}
}
}
private void SendToRedis(string json)
{
// PUBLISH channel message
SendCommand("PUBLISH", _channel, json);
// Optional list persistence
if (!string.IsNullOrEmpty(_listKey))
{
SendCommand("LPUSH", _listKey, json);
if (_maxListLength > 0)
{
SendCommand("LTRIM", _listKey, "0", (_maxListLength - 1).ToString());
}
}
Interlocked.Increment(ref _totalPublished);
}
private void EnsureConnected()
{
lock (_connLock)
{
if (_tcp != null && _tcp.Connected)
{
return;
}
Disconnect();
_tcp = new TcpClient();
_tcp.Connect(_host, _port);
_stream = _tcp.GetStream();
if (!string.IsNullOrEmpty(_password))
{
SendCommandRaw("AUTH", _password);
ReadResp(); // +OK
}
if (_database != 0)
{
SendCommandRaw("SELECT", _database.ToString());
ReadResp(); // +OK
}
}
}
private void Disconnect()
{
lock (_connLock)
{
if (_stream != null) { try { _stream.Dispose(); } catch { } _stream = null; }
if (_tcp != null) { try { _tcp.Dispose(); } catch { } _tcp = null; }
}
}
// Build RESP array and write + read response (inline, synchronous)
private void SendCommand(params string[] args)
{
SendCommandRaw(args);
ReadResp(); // discard but catch errors
}
private void SendCommandRaw(params string[] args)
{
var sb = new StringBuilder();
sb.Append('*').Append(args.Length).Append("\r\n");
foreach (string arg in args)
{
byte[] bytes = Encoding.UTF8.GetBytes(arg);
sb.Append('$').Append(bytes.Length).Append("\r\n");
// Append raw bytes via stream after the header
byte[] header = Encoding.ASCII.GetBytes(sb.ToString());
sb.Clear();
_stream.Write(header, 0, header.Length);
_stream.Write(bytes, 0, bytes.Length);
_stream.WriteByte(0x0D); // \r
_stream.WriteByte(0x0A); // \n
}
}
private void ReadResp()
{
// Read one RESP line we only need to consume the response,
// error checking is minimal (connection drop will be caught upstream)
int b = _stream.ReadByte();
if (b == -1)
{
throw new Exception("Redis connection closed.");
}
if ((char)b == '-') // error line
{
StringBuilder err = new StringBuilder();
int c;
while ((c = _stream.ReadByte()) != -1 && (char)c != '\r')
{
err.Append((char)c);
}
_stream.ReadByte(); // consume \n
throw new Exception("Redis error: " + err);
}
// Consume remainder of the line
while (true)
{
int c = _stream.ReadByte();
if (c == -1 || (char)c == '\n')
{
break;
}
}
}
private static string Serialize(LogEvent log)
{
var sb = new StringBuilder(256);
sb.Append("{\"ts\":\"");
sb.Append(LogEvent.GetDateTime(log.Timestamp).ToString("O"));
sb.Append("\",\"level\":\"");
sb.Append(LevelString(log.Level));
sb.Append("\",\"host\":\"");
JsonEscape(Environment.MachineName, sb);
sb.Append("\",\"category\":\"");
JsonEscape(log.Category ?? string.Empty, sb);
sb.Append("\",\"message\":\"");
JsonEscape(log.Message.Length > 0 ? log.Message.ToString() : string.Empty, sb);
sb.Append('"');
if (log.Exception != null)
{
sb.Append(",\"exception\":\"");
JsonEscape(log.Exception.ToString(), sb);
sb.Append('"');
}
if (log.Properties.Count > 0)
{
sb.Append(",\"props\":{");
bool first = true;
foreach (var kv in log.Properties.ToArray())
{
if (!first)
{
sb.Append(',');
}
first = false;
sb.Append('"');
JsonEscape(kv.Key, sb);
sb.Append("\":\"");
JsonEscape(kv.Value != null ? kv.Value.ToString() : "null", sb);
sb.Append('"');
}
sb.Append('}');
}
sb.Append('}');
return sb.ToString();
}
private static void JsonEscape(string value, StringBuilder sb)
{
if (string.IsNullOrEmpty(value))
{
return;
}
foreach (char c in value)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (char.IsControl(c)) { sb.Append("\\u"); sb.Append(((int)c).ToString("x4")); }
else
{
sb.Append(c);
}
break;
}
}
}
private static string LevelString(LogLevel level)
{
switch (level)
{
case LogLevel.Trace: return "TRACE";
case LogLevel.Debug: return "DEBUG";
case LogLevel.Information: return "INFO";
case LogLevel.Warning: return "WARN";
case LogLevel.Error: return "ERROR";
case LogLevel.Critical: return "CRITICAL";
default: return level.ToString().ToUpperInvariant();
}
}
}
}
@@ -0,0 +1,67 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.Flows;
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class RetryFlow : FlowBase
{
private readonly IFlow _inner;
private readonly int _maxRetries;
private readonly TimeSpan _initialDelay;
private readonly bool _exponentialBackoff;
public RetryFlow(
IFlow inner,
int maxRetries = 3,
TimeSpan? initialDelay = null,
bool exponentialBackoff = true)
: base($"Retry({_innerName(inner)})", inner.MinimumLevel)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_maxRetries = maxRetries;
_initialDelay = initialDelay ?? TimeSpan.FromMilliseconds(200);
_exponentialBackoff = exponentialBackoff;
}
public override async Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
int attempt = 0;
TimeSpan delay = _initialDelay;
while (true)
{
var result = await _inner.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false);
if (result == WriteResult.Success || attempt >= _maxRetries)
{
return result;
}
attempt++;
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
if (_exponentialBackoff)
{
delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
}
}
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
=> _inner.FlushAsync(cancellationToken);
public override ValueTask DisposeAsync()
=> _inner.DisposeAsync();
private static string _innerName(IFlow flow) => flow.Name ?? flow.GetType().Name;
}
}
@@ -0,0 +1,239 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// An in-process circular buffer that retains the last N log events in memory.
/// </summary>
public sealed class RollingBufferFlow : FlowBase
{
private readonly LogEvent[] _ring;
private readonly int _capacity;
private long _head; // next write position (mod capacity)
private long _count; // total ever written (not capped)
private readonly object _ringLock = new object();
private readonly LogLevel _triggerLevel;
private readonly IFlow _triggerTarget;
private readonly int _preContextLines;
/// <param name="capacity">Maximum number of events to retain.</param>
/// <param name="minimumLevel">Minimum level to store in the buffer.</param>
/// <param name="triggerLevel">
/// When a log event reaches this level or above, the current buffer
/// contents are immediately forwarded to <paramref name="triggerTarget"/>.
/// Set to <c>LogLevel.None</c> (or omit) to disable.
/// </param>
/// <param name="triggerTarget">
/// Flow to forward the buffered context to when the trigger fires.
/// Can be null even when <paramref name="triggerLevel"/> is set.
/// </param>
/// <param name="preContextLines">
/// How many buffered lines to forward before the triggering event.
/// Defaults to entire buffer (int.MaxValue).
/// </param>
public RollingBufferFlow(
int capacity = 500,
LogLevel minimumLevel = LogLevel.Trace,
LogLevel triggerLevel = LogLevel.Error,
IFlow triggerTarget = null,
int preContextLines = int.MaxValue)
: base("RollingBuffer", minimumLevel)
{
if (capacity < 1)
{
throw new ArgumentOutOfRangeException("capacity", "Must be >= 1.");
}
_capacity = capacity;
_ring = new LogEvent[capacity];
_triggerLevel = triggerLevel;
_triggerTarget = triggerTarget;
_preContextLines = preContextLines < 0 ? 0 : preContextLines;
}
/// <summary>Returns a snapshot of the buffer from oldest to newest.</summary>
public LogEvent[] GetAll()
{
lock (_ringLock)
{
long total = Math.Min(_count, _capacity);
if (total == 0)
{
return new LogEvent[0];
}
LogEvent[] result = new LogEvent[total];
long start = (_count > _capacity) ? _head : 0;
for (long i = 0; i < total; i++)
{
result[i] = _ring[(start + i) % _capacity];
}
return result;
}
}
/// <summary>Returns the N most recent events.</summary>
public LogEvent[] GetRecent(int n)
{
lock (_ringLock)
{
long total = Math.Min(_count, _capacity);
long take = Math.Min(n, total);
if (take <= 0)
{
return new LogEvent[0];
}
LogEvent[] result = new LogEvent[take];
long start = (_head - take + _capacity * 2) % _capacity;
for (long i = 0; i < take; i++)
{
result[i] = _ring[(start + i) % _capacity];
}
return result;
}
}
/// <summary>Returns all events at or above <paramref name="level"/>.</summary>
public LogEvent[] GetByLevel(LogLevel level)
{
LogEvent[] all = GetAll();
List<LogEvent> filtered = new List<LogEvent>(all.Length);
foreach (LogEvent e in all)
{
if (e.Level >= level)
{
filtered.Add(e);
}
}
return filtered.ToArray();
}
/// <summary>Number of events currently held in the buffer.</summary>
public int Count
{
get { lock (_ringLock) { return (int)Math.Min(_count, _capacity); } }
}
/// <summary>Total events ever written (may exceed capacity).</summary>
public long TotalWritten { get { return Interlocked.Read(ref _count); } }
/// <summary>Clears all stored events.</summary>
public void Clear()
{
lock (_ringLock)
{
Array.Clear(_ring, 0, _capacity);
_head = 0;
_count = 0;
}
}
public override Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
bool triggered = false;
lock (_ringLock)
{
_ring[_head % _capacity] = logEvent;
_head = (_head + 1) % _capacity;
_count++;
if (_triggerTarget != null
&& logEvent.Level >= _triggerLevel
&& _triggerLevel != LogLevel.None)
{
triggered = true;
}
}
Interlocked.Increment(ref BlastedCount);
if (triggered)
{
ForwardToTarget(logEvent);
}
return Task.FromResult(WriteResult.Success);
}
public override Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
foreach (LogEvent e in logEvents.ToArray())
{
BlastAsync(e, cancellationToken);
}
return Task.FromResult(WriteResult.Success);
}
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
=> Task.FromResult(0);
public override ValueTask DisposeAsync()
{
IsEnabled = false;
Clear();
return base.DisposeAsync();
}
private void ForwardToTarget(LogEvent triggeringEvent)
{
if (_triggerTarget == null)
{
return;
}
try
{
// Grab context window
int take = _preContextLines == int.MaxValue ? _capacity : _preContextLines;
LogEvent[] context = GetRecent(take);
foreach (LogEvent ev in context)
{
if (ev.Equals(triggeringEvent))
{
continue; // avoid duplicate
}
_triggerTarget.BlastAsync(ev).GetAwaiter().GetResult();
}
// Always forward the triggering event last
_triggerTarget.BlastAsync(triggeringEvent).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Console.Error.WriteLine("[RollingBufferFlow] Trigger forward error: " + ex.Message);
}
}
}
}
@@ -0,0 +1,317 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Pushes log events to a SignalR hub via HTTP POST to the hub's /send endpoint.
/// Works with ASP.NET SignalR (classic) and ASP.NET Core SignalR server-side REST API.
///
/// A lightweight alternative to the SignalR client library
///
/// On the server side you need a minimal hub endpoint that accepts POST:
/// POST {hubUrl}/send body: { "target": "...", "arguments": [ { log json } ] }
///
/// For live dashboards: the hub broadcasts to a "logs" group; clients subscribe and
/// render events in real time.
/// </summary>
public sealed class SignalRFlow : FlowBase
{
private readonly string _hubUrl;
private readonly string _hubMethod;
private readonly HttpClient _http;
private readonly bool _ownsHttpClient;
private readonly int _batchSize;
private readonly TimeSpan _batchInterval;
private readonly List<string> _pending = new List<string>();
private readonly object _lock = new object();
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly Thread _senderThread;
private long _totalSent;
public SignalRFlow(
string hubUrl,
string hubMethod = "ReceiveLog",
HttpClient httpClient = null,
int batchSize = 20,
int batchIntervalMs = 500,
LogLevel minimumLevel = LogLevel.Information)
: base("SignalR:" + hubUrl, minimumLevel)
{
if (hubUrl == null)
{
throw new ArgumentNullException("hubUrl");
}
_hubUrl = hubUrl.TrimEnd('/');
_hubMethod = hubMethod ?? "ReceiveLog";
_batchSize = batchSize < 1 ? 1 : batchSize;
_batchInterval = TimeSpan.FromMilliseconds(batchIntervalMs < 50 ? 50 : batchIntervalMs);
if (httpClient == null)
{
_http = new HttpClient();
_http.Timeout = TimeSpan.FromSeconds(10);
_ownsHttpClient = true;
}
else
{
_http = httpClient;
_ownsHttpClient = false;
}
_senderThread = new Thread(SenderLoop)
{
IsBackground = true,
Name = "SignalRFlow.Sender",
Priority = ThreadPriority.BelowNormal
};
_senderThread.Start();
}
public long TotalSent { get { return Interlocked.Read(ref _totalSent); } }
public override Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
string json = Serialize(logEvent);
bool sendNow = false;
lock (_lock)
{
_pending.Add(json);
if (_pending.Count >= _batchSize)
{
sendNow = true;
}
}
if (sendNow)
{
DispatchBatch();
}
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
public override Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled)
{
return Task.FromResult(WriteResult.FlowDisabled);
}
foreach (LogEvent e in logEvents.ToArray())
{
if (IsLogLevelEnabled(e))
{
BlastAsync(e, cancellationToken);
}
}
return Task.FromResult(WriteResult.Success);
}
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
{
DispatchBatch();
return Task.FromResult(0);
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_cts.Cancel();
_senderThread.Join(TimeSpan.FromSeconds(5));
DispatchBatch();
if (_ownsHttpClient)
{
_http.Dispose();
}
_cts.Dispose();
await base.DisposeAsync().ConfigureAwait(false);
}
private void SenderLoop()
{
while (!_cts.Token.IsCancellationRequested)
{
try { Thread.Sleep(_batchInterval); }
catch (ThreadInterruptedException) { break; }
DispatchBatch();
}
}
private void DispatchBatch()
{
List<string> batch;
lock (_lock)
{
if (_pending.Count == 0)
{
return;
}
batch = new List<string>(_pending);
_pending.Clear();
}
ThreadPool.QueueUserWorkItem(_ =>
{
try { SendBatch(batch); }
catch (Exception ex)
{
Console.Error.WriteLine("[SignalRFlow] Send error: " + ex.Message);
Interlocked.Add(ref DroppedCount, batch.Count);
}
});
}
private void SendBatch(List<string> jsonEvents)
{
// Build envelope: { "target": "ReceiveLog", "arguments": [ [...events...] ] }
var sb = new StringBuilder(jsonEvents.Count * 200 + 64);
sb.Append("{\"target\":\"");
JsonEscape(_hubMethod, sb);
sb.Append("\",\"arguments\":[[");
for (int i = 0; i < jsonEvents.Count; i++)
{
if (i > 0)
{
sb.Append(',');
}
sb.Append(jsonEvents[i]);
}
sb.Append("]]}");
string payload = sb.ToString();
StringContent content = new StringContent(payload, Encoding.UTF8, "application/json");
HttpResponseMessage resp = _http.PostAsync(_hubUrl + "/send", content).GetAwaiter().GetResult();
if (resp.IsSuccessStatusCode)
{
Interlocked.Add(ref _totalSent, jsonEvents.Count);
}
else
{
Console.Error.WriteLine("[SignalRFlow] HTTP " + (int)resp.StatusCode + " from hub.");
Interlocked.Add(ref DroppedCount, jsonEvents.Count);
}
}
private static string Serialize(LogEvent log)
{
var sb = new StringBuilder(256);
sb.Append('{');
sb.Append("\"ts\":\"");
sb.Append(LogEvent.GetDateTime(log.Timestamp).ToString("O"));
sb.Append("\",\"level\":\"");
sb.Append(LevelString(log.Level));
sb.Append("\",\"category\":\"");
JsonEscape(log.Category ?? string.Empty, sb);
sb.Append("\",\"message\":\"");
JsonEscape(log.Message.Length > 0 ? log.Message.ToString() : string.Empty, sb);
sb.Append('"');
if (log.Exception != null)
{
sb.Append(",\"exception\":\"");
JsonEscape(log.Exception.GetType().Name + ": " + log.Exception.Message, sb);
sb.Append('"');
}
if (log.Properties.Count > 0)
{
sb.Append(",\"props\":{");
bool first = true;
foreach (var kv in log.Properties.ToArray())
{
if (!first)
{
sb.Append(',');
}
first = false;
sb.Append('"');
JsonEscape(kv.Key, sb);
sb.Append("\":\"");
JsonEscape(kv.Value != null ? kv.Value.ToString() : "null", sb);
sb.Append('"');
}
sb.Append('}');
}
sb.Append('}');
return sb.ToString();
}
private static void JsonEscape(string value, StringBuilder sb)
{
if (string.IsNullOrEmpty(value))
{
return;
}
foreach (char c in value)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (char.IsControl(c))
{
sb.Append("\\u");
sb.Append(((int)c).ToString("x4"));
}
else
{
sb.Append(c);
}
break;
}
}
}
private static string LevelString(LogLevel level)
{
switch (level)
{
case LogLevel.Trace: return "TRACE";
case LogLevel.Debug: return "DEBUG";
case LogLevel.Information: return "INFO";
case LogLevel.Warning: return "WARN";
case LogLevel.Error: return "ERROR";
case LogLevel.Critical: return "CRITICAL";
default: return level.ToString().ToUpperInvariant();
}
}
}
}
@@ -0,0 +1,176 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
/// <summary>
/// logging flow that sends messages to a Slack channel via webhook.
/// </summary>
public sealed class SlackFlow : FlowBase, IAsyncDisposable
{
private const int ChannelCapacity = 4096;
private readonly int _batchSize;
private readonly Channel<LogEvent> _channel;
private readonly Task _workerTask;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
public SlackFlow(
string webhookUrl,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Information)
: base("Slack", minimumLevel)
{
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
_httpClient = new HttpClient();
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_batchSize = batchSize <= 0 ? 1 : batchSize;
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_workerTask = Task.Run(() => ProcessQueueAsync(_cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
try
{
while (await _channel.Reader.WaitToReadAsync(cancellationToken))
{
while (_channel.Reader.TryRead(out var logEvent))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(batch, cancellationToken);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"SlackFlow error: {ex.Message}");
}
}
private async Task SendBatchAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
var payload = new
{
text = BuildMessage(logEvent)
};
var json = JsonHelper.ToJson(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await _httpClient.PostAsync(_webhookUrl, content, cancellationToken);
}
}
private static string BuildMessage(LogEvent logEvent)
{
var sb = new StringBuilder();
sb.AppendLine($"*Level:* {logEvent.Level}");
if (!string.IsNullOrWhiteSpace(logEvent.Category))
{
sb.AppendLine($"*Category:* {logEvent.Category}");
}
sb.AppendLine($"*Timestamp:* {LogEvent.GetDateTime(logEvent.Timestamp):yyyy-MM-dd HH:mm:ss.fff}");
sb.AppendLine($"*Message:* {logEvent.Message}");
if (logEvent.Exception != null)
{
sb.AppendLine("*Exception:*");
sb.AppendLine($"```{logEvent.Exception.GetType().FullName}: {logEvent.Exception.Message}\n{logEvent.Exception.StackTrace}```");
}
if (logEvent.Properties.Count > 0)
{
sb.AppendLine("*Properties:*");
foreach (var prop in logEvent.Properties)
{
sb.AppendLine($"`{prop.Key}` = `{prop.Value?.ToString() ?? "null"}`");
}
}
return sb.ToString();
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
_httpClient.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,69 @@
using EonaCat.LogStack.Core;
using System;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
public class SnmpTrapFlow : FlowBase
{
private readonly string _host;
private readonly int _port;
private readonly string _oid;
private readonly UdpClient _udpClient;
public SnmpTrapFlow(string host, int port = 162, string oid = "1.3.6.1.4.1.99999.1337.1.1.1", LogLevel minimumLevel = LogLevel.Trace) : base($"SNMP:{host}:{port}", minimumLevel)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_port = port;
_oid = oid;
_udpClient = new UdpClient();
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
var snmpTrapMessage = FormatSnmpTrapMessage(logEvent);
var data = Encoding.ASCII.GetBytes(snmpTrapMessage);
try
{
_udpClient.Send(data, data.Length, _host, _port);
return Task.FromResult(WriteResult.Success);
}
catch
{
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
}
private string FormatSnmpTrapMessage(LogEvent logEvent)
{
var stringBuilder = new StringBuilder();
stringBuilder.Append($"Trap OID: {_oid}");
stringBuilder.Append(" Timestamp: ").Append(logEvent.Timestamp);
stringBuilder.Append(" Level: ").Append(logEvent.Level);
stringBuilder.Append(" Message: ").Append(logEvent.Message);
return stringBuilder.ToString();
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
{
// SNMP traps are sent immediately, so no flushing needed.
return Task.CompletedTask;
}
public override ValueTask DisposeAsync()
{
_udpClient?.Dispose();
return base.DisposeAsync();
}
}
}
@@ -0,0 +1,203 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
namespace EonaCat.LogStack.Flows
{
public sealed class SplunkFlow : FlowBase
{
private readonly int _batchSize;
private const int ChannelCapacity = 4096;
private readonly Channel<LogEvent> _channel;
private readonly Task _senderTask;
private readonly CancellationTokenSource _cts;
private readonly string _splunkUrl;
private readonly string _token;
private readonly string _sourcetype;
private readonly string _hostName;
private readonly BackpressureStrategy _backpressureStrategy;
private readonly HttpClient _httpClient;
public SplunkFlow(
string splunkUrl,
string token,
string sourcetype = "splunk_logs",
string hostName = null,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace,
BackpressureStrategy backpressureStrategy = BackpressureStrategy.DropOldest)
: base($"Splunk:{splunkUrl}", minimumLevel)
{
_splunkUrl = splunkUrl ?? throw new ArgumentNullException(nameof(splunkUrl));
_token = token ?? throw new ArgumentNullException(nameof(token));
_sourcetype = sourcetype;
_hostName = hostName ?? Environment.MachineName;
_backpressureStrategy = backpressureStrategy;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = backpressureStrategy switch
{
BackpressureStrategy.Wait => BoundedChannelFullMode.Wait,
BackpressureStrategy.DropNewest => BoundedChannelFullMode.DropWrite,
BackpressureStrategy.DropOldest => BoundedChannelFullMode.DropOldest,
_ => BoundedChannelFullMode.Wait
},
SingleReader = true,
SingleWriter = false
};
_batchSize = batchSize <= 0 ? 1 : batchSize;
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Splunk {_token}");
_senderTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
var result = WriteResult.Success;
foreach (var logEvent in logEvents.Span)
{
if (!IsLogLevelEnabled(logEvent))
{
continue;
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
}
else
{
Interlocked.Increment(ref DroppedCount);
result = WriteResult.Dropped;
}
}
return result;
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await foreach (var logEvent in _channel.Reader.ReadAllAsync(cancellationToken))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize || _channel.Reader.Count == 0)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"SplunkFlow error: {ex.Message}");
await Task.Delay(1000, cancellationToken);
}
}
}
private async Task SendBatchAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
var splunkEvent = new
{
time = ToUnixTimeSeconds(LogEvent.GetDateTime(logEvent.Timestamp)),
host = _hostName,
sourcetype = _sourcetype,
@event = new
{
level = logEvent.Level.ToString(),
category = logEvent.Category,
message = logEvent.Message
}
};
string json = JsonHelper.ToJson(splunkEvent);
var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
await _httpClient.PostAsync(_splunkUrl, content, cancellationToken);
}
catch
{
// ignore errors
}
}
}
// Helper to convert DateTime to Unix timestamp (works in .NET 4.8.x)
private static double ToUnixTimeSeconds(DateTime dt)
{
var utc = dt.ToUniversalTime();
var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
return (utc - epoch).TotalSeconds;
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try { await _senderTask.ConfigureAwait(false); } catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try { await _senderTask.ConfigureAwait(false); } catch { }
_httpClient.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,320 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.Flows;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace ServiceMonitoring
{
public enum ServiceType
{
TCP = 0,
UDP,
HTTP,
HTTPS,
File
}
public class ServiceStatus
{
public string ServiceName { get; set; }
public string Host { get; set; }
public int Port { get; set; }
public string Status { get; set; }
public DateTime LastChecked { get; set; }
public ServiceType ServiceType { get; set; }
public string AdditionalInfo { get; set; }
}
public sealed class StatusFlow : FlowBase
{
private readonly List<ServiceStatus> _servicesToMonitor;
private readonly TimeSpan _checkInterval;
private readonly string _statusDirectory;
private readonly CancellationTokenSource _cts;
private readonly Action<ServiceStatus> _statusChangeTrigger;
/// <summary>
/// Log fileSize (default: 10 MB)
/// </summary>
public int MaxLogFileSize { get; set; } = 10 * 1024 * 1024; // 10 MB
/// <summary>
/// Max Log files (default: 5)
/// </summary>
public int MaxLogFiles { get; set; } = 5;
/// <summary>
/// Default interval in minutes (default: 5)
/// </summary>
public int DefaultIntervalCheckInMinutes { get; set; } = 5;
public StatusFlow(
List<ServiceStatus> servicesToMonitor,
TimeSpan? checkInterval,
string statusDirectory,
Action<ServiceStatus> statusChangeTrigger,
LogLevel minimumLevel = LogLevel.Trace
) : base("StatusFlow", minimumLevel)
{
_servicesToMonitor = servicesToMonitor;
if (checkInterval == null)
{
checkInterval = TimeSpan.FromMinutes(DefaultIntervalCheckInMinutes);
}
_checkInterval = checkInterval.Value;
_statusChangeTrigger = statusChangeTrigger;
if (string.IsNullOrWhiteSpace(statusDirectory))
{
statusDirectory = "./logs/status";
}
// Resolve relative path
if (statusDirectory.StartsWith("./", StringComparison.Ordinal))
{
statusDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, statusDirectory.Substring(2));
}
Directory.CreateDirectory(statusDirectory);
_statusDirectory = statusDirectory;
_cts = new CancellationTokenSource();
StartMonitoring();
}
public void StartMonitoring()
{
Task.Run(async () =>
{
while (IsEnabled && !_cts.Token.IsCancellationRequested)
{
await MonitorServicesAsync();
await Task.Delay(_checkInterval, _cts.Token);
}
});
}
private async Task MonitorServicesAsync()
{
foreach (var service in _servicesToMonitor)
{
bool isServiceAvailable = service.ServiceType switch
{
ServiceType.TCP => await IsTcpServiceAvailableAsync(service.Host, service.Port),
ServiceType.UDP => await IsUdpServiceAvailableAsync(service.Host, service.Port),
ServiceType.HTTP => await IsHttpServiceAvailableAsync(service.Host),
ServiceType.HTTPS => await IsHttpsServiceAvailableAsync(service),
ServiceType.File => await IsFileAvailableAsync(service.Host),
_ => false
};
if (isServiceAvailable != (service.Status == "Available"))
{
service.Status = isServiceAvailable ? "Available" : "Unavailable";
service.LastChecked = DateTime.UtcNow;
// Trigger action when service status changes
_statusChangeTrigger?.Invoke(service);
// Log the status
LogServiceStatusToFile(service);
}
}
}
public override async Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref DroppedCount);
return WriteResult.NoBlastZone;
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref DroppedCount);
return WriteResult.NoBlastZone;
}
private async Task<bool> IsTcpServiceAvailableAsync(string host, int port)
{
try
{
using (var tcpClient = new TcpClient())
{
await tcpClient.ConnectAsync(host, port);
return true;
}
}
catch
{
return false;
}
}
private async Task<bool> IsUdpServiceAvailableAsync(string host, int port)
{
try
{
using (var udpClient = new UdpClient())
{
var timeout = TimeSpan.FromSeconds(5);
var message = Encoding.ASCII.GetBytes("ping");
var sendTask = udpClient.SendAsync(message, message.Length, host, port);
var completedTask = await Task.WhenAny(sendTask, Task.Delay(timeout));
if (completedTask == sendTask)
{
var receiveTask = udpClient.ReceiveAsync();
var completedReceiveTask = await Task.WhenAny(receiveTask, Task.Delay(timeout));
return completedReceiveTask == receiveTask;
}
return false;
}
}
catch
{
return false;
}
}
private async Task<bool> IsHttpServiceAvailableAsync(string host)
{
try
{
var request = (HttpWebRequest)WebRequest.Create($"http://{host}");
request.Method = "HEAD";
using (var response = await request.GetResponseAsync())
{
return true;
}
}
catch
{
return false;
}
}
public async Task<bool> IsHttpsServiceAvailableAsync(ServiceStatus service)
{
try
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) =>
{
if (sslPolicyErrors != System.Net.Security.SslPolicyErrors.None)
{
service.AdditionalInfo = $"Certificate Subject: {cert.Subject}\n" +
$"Certificate Issuer: {cert.Issuer}\n" +
$"Certificate Expiry: {cert.GetExpirationDateString()}\n" +
$"SSL Policy Errors: {sslPolicyErrors}\n";
foreach (var chainElement in chain.ChainElements)
{
service.AdditionalInfo += $"Chain Element: {chainElement.Certificate.Subject}, {chainElement.Certificate.Issuer}\n";
}
}
return sslPolicyErrors == System.Net.Security.SslPolicyErrors.None;
}
};
using (var client = new HttpClient(handler))
{
var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, $"https://{service.Host}"));
return response.IsSuccessStatusCode;
}
}
catch (Exception ex)
{
service.AdditionalInfo = $"Error: {ex.Message}";
return false;
}
}
private async Task<bool> IsFileAvailableAsync(string filePath)
{
try
{
return File.Exists(filePath);
}
catch
{
return false;
}
}
private void LogServiceStatusToFile(ServiceStatus service)
{
string statusMessage = $"{service.ServiceName} ({service.Host}:{service.Port}) - Status: {service.Status}, Last Checked: {service.LastChecked}";
if (!string.IsNullOrEmpty(service.AdditionalInfo))
{
statusMessage += $"\nAdditional Info: {service.AdditionalInfo}";
}
RollOverLogFileIfNeeded();
try
{
string logFilePath = Path.Combine(_statusDirectory, "status_log.txt");
using (var writer = new StreamWriter(logFilePath, append: true))
{
writer.WriteLine(statusMessage);
}
}
catch (Exception ex)
{
Console.WriteLine($"StatusFlow: Error writing to file: {ex.Message}");
}
}
private void RollOverLogFileIfNeeded()
{
try
{
string logFilePath = Path.Combine(_statusDirectory, "status_log.txt");
if (File.Exists(logFilePath) && new FileInfo(logFilePath).Length > MaxLogFileSize)
{
var logFiles = Directory.GetFiles(_statusDirectory, "status_log_*.txt").OrderBy(f => f).ToList();
if (logFiles.Count >= MaxLogFiles)
{
File.Delete(logFiles[0]);
logFiles.RemoveAt(0);
}
string newLogFilePath = Path.Combine(_statusDirectory, $"status_log_{DateTime.UtcNow:yyyyMMdd_HHmmss}.txt");
File.Move(logFilePath, newLogFilePath);
}
}
catch (Exception ex)
{
Console.WriteLine($"StatusFlow: Error handling log file rollover: {ex.Message}");
}
}
public override async ValueTask DisposeAsync()
{
_cts.Cancel();
await base.DisposeAsync();
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,270 @@
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Security;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class SyslogTcpFlow : FlowBase
{
private readonly int _batchSize;
private const int ChannelCapacity = 4096;
private readonly Channel<LogEvent> _channel;
private readonly Task _senderTask;
private readonly CancellationTokenSource _cts;
private readonly string _host;
private readonly int _port;
private TcpClient? _tcpClient;
private Stream _stream;
private readonly bool _useTls;
private readonly RemoteCertificateValidationCallback _certValidationCallback;
private readonly X509CertificateCollection _clientCertificates;
private readonly BackpressureStrategy _backpressureStrategy;
public SyslogTcpFlow(
string host,
int port = 514,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace,
BackpressureStrategy backpressureStrategy = BackpressureStrategy.DropOldest,
bool useTls = false,
RemoteCertificateValidationCallback certValidationCallback = null,
X509CertificateCollection clientCertificates = null)
: base($"SyslogTCP:{host}:{port}", minimumLevel)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_port = port;
_backpressureStrategy = backpressureStrategy;
_batchSize = batchSize <= 0 ? 1 : batchSize;
_useTls = useTls;
_certValidationCallback = certValidationCallback ?? DefaultCertificateValidation;
_clientCertificates = clientCertificates;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = backpressureStrategy == BackpressureStrategy.Wait
? BoundedChannelFullMode.Wait
: backpressureStrategy == BackpressureStrategy.DropNewest
? BoundedChannelFullMode.DropWrite
: BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_senderTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
var result = WriteResult.Success;
foreach (var logEvent in logEvents.Span)
{
if (!IsLogLevelEnabled(logEvent))
{
continue;
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
}
else
{
Interlocked.Increment(ref DroppedCount);
result = WriteResult.Dropped;
}
}
return result;
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
var sb = new StringBuilder(8192);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await EnsureConnectedAsync(cancellationToken);
await foreach (var logEvent in _channel.Reader.ReadAllAsync(cancellationToken))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize || _channel.Reader.Count == 0)
{
await SendBatchAsync(batch, sb, cancellationToken);
batch.Clear();
sb.Clear();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"SyslogTcpFlow error: {ex.Message}");
await Task.Delay(1000, cancellationToken);
_tcpClient?.Dispose();
_tcpClient = null;
}
}
}
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
if (_tcpClient != null && _tcpClient.Connected)
{
return;
}
if (_stream != null)
{
_stream.Dispose();
_stream = null;
}
if (_tcpClient != null)
{
_tcpClient.Dispose();
_tcpClient = null;
}
_tcpClient = new TcpClient();
_tcpClient.NoDelay = true;
await _tcpClient.ConnectAsync(_host, _port).ConfigureAwait(false);
var networkStream = _tcpClient.GetStream();
if (_useTls)
{
var sslStream = new SslStream(
networkStream,
false,
_certValidationCallback);
sslStream.AuthenticateAsClient(
_host,
_clientCertificates,
SslProtocols.Tls12,
checkCertificateRevocation: true);
_stream = sslStream;
}
else
{
_stream = networkStream;
}
}
private static bool DefaultCertificateValidation(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
return sslPolicyErrors == SslPolicyErrors.None;
}
private async Task SendBatchAsync(List<LogEvent> batch, StringBuilder sb, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
FormatSyslogEvent(logEvent, sb);
sb.AppendLine();
}
if (_stream != null)
{
var data = Encoding.UTF8.GetBytes(sb.ToString());
await _stream.WriteAsync(data, 0, data.Length, cancellationToken).ConfigureAwait(false);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void FormatSyslogEvent(LogEvent logEvent, StringBuilder sb)
{
// Simple RFC 3164-style format: <PRI>timestamp hostname tag: message
// Here we use facility=1 (user-level messages) and map severity from log level
int severity = logEvent.Level switch
{
LogLevel.Trace => 7,
LogLevel.Debug => 7,
LogLevel.Information => 6,
LogLevel.Warning => 4,
LogLevel.Error => 3,
LogLevel.Critical => 2,
_ => 6
};
int facility = 1; // user-level messages
int pri = facility * 8 + severity;
var dt = LogEvent.GetDateTime(logEvent.Timestamp);
sb.Append('<').Append(pri).Append('>');
sb.Append(dt.ToString("MMM dd HH:mm:ss")); // RFC 3164 timestamp
sb.Append(" ").Append(Environment.MachineName);
sb.Append(" ").Append(string.IsNullOrEmpty(logEvent.Category) ? "SyslogTcpFlow" : logEvent.Category);
sb.Append(": ").Append(logEvent.Message);
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try { await _senderTask.ConfigureAwait(false); } catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try { await _senderTask.ConfigureAwait(false); } catch { }
_stream?.Dispose();
_tcpClient?.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,238 @@
using EonaCat.LogStack.Core;
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class SyslogUdpFlow : FlowBase
{
private readonly int _batchSize;
private const int ChannelCapacity = 4096;
private const int MaxUdpPacketSize = 4096;
private readonly Channel<LogEvent> _channel;
private readonly Task _senderTask;
private readonly CancellationTokenSource _cts;
private readonly string _host;
private readonly int _port;
private UdpClient? _udpClient;
private readonly BackpressureStrategy _backpressureStrategy;
public SyslogUdpFlow(
string host,
int port = 514,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace,
BackpressureStrategy backpressureStrategy = BackpressureStrategy.DropOldest)
: base($"SyslogUDP:{host}:{port}", minimumLevel)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_port = port;
_backpressureStrategy = backpressureStrategy;
_batchSize = batchSize <= 0 ? 1 : batchSize;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = backpressureStrategy switch
{
BackpressureStrategy.Wait => BoundedChannelFullMode.Wait,
BackpressureStrategy.DropNewest => BoundedChannelFullMode.DropWrite,
BackpressureStrategy.DropOldest => BoundedChannelFullMode.DropOldest,
_ => BoundedChannelFullMode.Wait
},
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_udpClient = new UdpClient();
_senderTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
var result = WriteResult.Success;
foreach (var logEvent in logEvents.Span)
{
if (!IsLogLevelEnabled(logEvent))
{
continue;
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
}
else
{
Interlocked.Increment(ref DroppedCount);
result = WriteResult.Dropped;
}
}
return result;
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
var sb = new StringBuilder(8192);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await foreach (var logEvent in _channel.Reader.ReadAllAsync(cancellationToken))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize || _channel.Reader.Count == 0)
{
await SendBatchAsync(batch, sb, cancellationToken);
batch.Clear();
sb.Clear();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"SyslogUdpFlow error: {ex.Message}");
await Task.Delay(500, cancellationToken);
}
}
}
private async Task SendBatchAsync(List<LogEvent> batch, StringBuilder sb, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
FormatSyslogEvent(logEvent, sb);
sb.AppendLine();
}
if (_udpClient == null)
{
return;
}
var data = Encoding.UTF8.GetBytes(sb.ToString());
if (data.Length <= MaxUdpPacketSize)
{
try
{
await _udpClient.SendAsync(data, data.Length, _host, _port);
}
catch
{
// UDP send errors are ignored
}
}
else
{
await SendUdpInChunksAsync(data, MaxUdpPacketSize, cancellationToken);
}
}
private async Task SendUdpInChunksAsync(byte[] data, int chunkSize, CancellationToken cancellationToken)
{
int offset = 0;
byte[] buffer = ArrayPool<byte>.Shared.Rent(chunkSize);
try
{
while (offset < data.Length)
{
int size = Math.Min(chunkSize, data.Length - offset);
Buffer.BlockCopy(data, offset, buffer, 0, size);
await _udpClient.SendAsync(buffer, size, _host, _port);
offset += size;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void FormatSyslogEvent(LogEvent logEvent, StringBuilder sb)
{
int severity = logEvent.Level switch
{
LogLevel.Trace => 7,
LogLevel.Debug => 7,
LogLevel.Information => 6,
LogLevel.Warning => 4,
LogLevel.Error => 3,
LogLevel.Critical => 2,
_ => 6
};
int facility = 1;
int pri = facility * 8 + severity;
var dt = LogEvent.GetDateTime(logEvent.Timestamp);
sb.Append('<').Append(pri).Append('>');
sb.Append(dt.ToString("MMM dd HH:mm:ss"));
sb.Append(" ").Append(Environment.MachineName);
sb.Append(" ").Append(string.IsNullOrEmpty(logEvent.Category) ? "SyslogUdpFlow" : logEvent.Category);
sb.Append(": ").Append(logEvent.Message);
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try { await _senderTask.ConfigureAwait(false); } catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try { await _senderTask.ConfigureAwait(false); } catch { }
_udpClient?.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,289 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.Flows;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Security;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows;
public sealed class TcpFlow : FlowBase
{
private readonly int _batchSize;
private const int ChannelCapacity = 4096;
private readonly Channel<LogEvent> _channel;
private readonly Task _senderTask;
private readonly CancellationTokenSource _cts;
private readonly string _host;
private readonly int _port;
private TcpClient? _tcpClient;
private Stream _stream;
private readonly bool _useTls;
private readonly RemoteCertificateValidationCallback _certValidationCallback;
private readonly X509CertificateCollection _clientCertificates;
private readonly BackpressureStrategy _backpressureStrategy;
public TcpFlow(
string host,
int port,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace,
BackpressureStrategy backpressureStrategy = BackpressureStrategy.DropOldest,
bool useTls = false,
RemoteCertificateValidationCallback certValidationCallback = null,
X509CertificateCollection clientCertificates = null)
: base($"TCP:{host}:{port}", minimumLevel)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_port = port;
_backpressureStrategy = backpressureStrategy;
_batchSize = batchSize <= 0 ? 1 : batchSize;
_useTls = useTls;
_certValidationCallback = certValidationCallback ?? DefaultCertificateValidation;
_clientCertificates = clientCertificates;
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = backpressureStrategy == BackpressureStrategy.Wait
? BoundedChannelFullMode.Wait
: backpressureStrategy == BackpressureStrategy.DropNewest
? BoundedChannelFullMode.DropWrite
: BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_senderTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public async Task<WriteResult> SendFileAsync(string filePath, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
return WriteResult.Failed;
}
try
{
// Ensure TCP connection
await EnsureConnectedAsync(cancellationToken);
// Send file in chunks
using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
{
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0)
{
await _stream.WriteAsync(buffer, 0, bytesRead, cancellationToken);
await _stream.FlushAsync(cancellationToken);
}
}
return WriteResult.Success;
}
catch (Exception exception)
{
Console.Error.WriteLine($"TcpFlow error: Error while sending file: {exception.Message}");
return WriteResult.Failed;
}
}
private static bool DefaultCertificateValidation(
object sender,
X509Certificate certificate,
X509Chain chain,
SslPolicyErrors sslPolicyErrors)
{
return sslPolicyErrors == SslPolicyErrors.None;
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
var result = WriteResult.Success;
foreach (var logEvent in logEvents.Span)
{
if (!IsLogLevelEnabled(logEvent))
{
continue;
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
}
else
{
Interlocked.Increment(ref DroppedCount);
result = WriteResult.Dropped;
}
}
return result;
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
var sb = new StringBuilder(8192);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await EnsureConnectedAsync(cancellationToken);
await foreach (var logEvent in _channel.Reader.ReadAllAsync(cancellationToken))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize || _channel.Reader.Count == 0)
{
await SendBatchAsync(batch, sb, cancellationToken);
batch.Clear();
sb.Clear();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"TcpFlow error: {ex.Message}");
await Task.Delay(1000, cancellationToken); // Retry after delay
_tcpClient?.Dispose();
_tcpClient = null;
}
}
}
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
if (_tcpClient != null && _tcpClient.Connected)
{
return;
}
_stream?.Dispose();
_tcpClient?.Dispose();
_tcpClient = null;
_tcpClient = new TcpClient { NoDelay = true }; // lower latency
await _tcpClient.ConnectAsync(_host, _port).ConfigureAwait(false);
var networkStream = _tcpClient.GetStream();
if (_useTls)
{
var sslStream = new SslStream(
networkStream,
false,
_certValidationCallback);
await sslStream.AuthenticateAsClientAsync(
_host,
_clientCertificates,
SslProtocols.Tls12,
checkCertificateRevocation: true).ConfigureAwait(false);
_stream = sslStream;
}
else
{
_stream = networkStream;
}
}
private async Task SendBatchAsync(List<LogEvent> batch, StringBuilder sb, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
FormatLogEvent(logEvent, sb);
sb.AppendLine();
}
if (_stream != null)
{
var data = Encoding.UTF8.GetBytes(sb.ToString());
await _stream.WriteAsync(data, 0, data.Length, cancellationToken).ConfigureAwait(false);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void FormatLogEvent(LogEvent logEvent, StringBuilder sb)
{
var dt = LogEvent.GetDateTime(logEvent.Timestamp);
sb.Append(dt.ToString("yyyy-MM-dd HH:mm:ss.fff"));
sb.Append(" [");
sb.Append(logEvent.Level.ToString().ToUpperInvariant());
sb.Append("] ");
if (!string.IsNullOrEmpty(logEvent.Category))
{
sb.Append(logEvent.Category);
sb.Append(": ");
}
sb.Append(logEvent.Message);
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try { await _senderTask.ConfigureAwait(false); } catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try { await _senderTask.ConfigureAwait(false); } catch { }
_stream?.Dispose();
_tcpClient?.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
@@ -0,0 +1,183 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// logging flow that sends messages to a Telegram chat via a bot.
/// </summary>
public sealed class TelegramFlow : FlowBase, IAsyncDisposable
{
private const int ChannelCapacity = 4096;
private readonly int _batchSize;
private readonly Channel<LogEvent> _channel;
private readonly Task _workerTask;
private readonly CancellationTokenSource _cts;
private readonly HttpClient _httpClient;
private readonly string _botToken;
private readonly string _chatId;
public TelegramFlow(
string botToken,
string chatId,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Information)
: base("Telegram", minimumLevel)
{
_botToken = botToken ?? throw new ArgumentNullException(nameof(botToken));
_chatId = chatId ?? throw new ArgumentNullException(nameof(chatId));
_httpClient = new HttpClient();
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
};
_batchSize = batchSize <= 0 ? 1 : batchSize;
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_workerTask = Task.Run(() => ProcessQueueAsync(_cts.Token));
}
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
private async Task ProcessQueueAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
try
{
while (await _channel.Reader.WaitToReadAsync(cancellationToken))
{
while (_channel.Reader.TryRead(out var logEvent))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(batch, cancellationToken);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"TelegramFlow error: {ex.Message}");
}
}
private async Task SendBatchAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
var message = BuildMessage(logEvent);
var url = $"https://api.telegram.org/bot{_botToken}/sendMessage";
var payload = new
{
chat_id = _chatId,
text = message,
parse_mode = "Markdown"
};
var json = JsonHelper.ToJson(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await _httpClient.PostAsync(url, content, cancellationToken);
}
}
private static string BuildMessage(LogEvent logEvent)
{
var sb = new StringBuilder();
sb.Append($"*{logEvent.Level}* | {logEvent.Category}\n");
sb.Append($"`{LogEvent.GetDateTime(logEvent.Timestamp):yyyy-MM-dd HH:mm:ss.fff}`\n");
sb.Append(logEvent.Message);
if (logEvent.Exception != null)
{
sb.Append($"\n*Exception:* `{logEvent.Exception.GetType().FullName}`\n");
sb.Append($"`{logEvent.Exception.Message}`\n");
}
if (logEvent.Properties.Count > 0)
{
sb.Append("\n*Properties:*");
foreach (var prop in logEvent.Properties)
{
sb.Append($"\n`{prop.Key}` = `{prop.Value?.ToString() ?? "null"}`");
}
}
return sb.ToString();
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try
{
await _workerTask.ConfigureAwait(false);
}
catch { }
_httpClient.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,303 @@
using EonaCat.LogStack.Core;
using EonaCat.LogStack.EonaCatLogStackCore;
using Microsoft.Extensions.Primitives;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// A decorator flow that applies per-level rate limiting (token bucket) to any
/// inner flow. Prevents log storms from overwhelming downstream sinks (e.g. Slack,
/// HTTP, email) while ensuring that at least one event of each pattern gets through.
///
/// Also supports deduplication: identical messages within a window are collapsed
/// into a single entry with a repeat-count.
/// </summary>
public sealed class ThrottledFlow : FlowBase
{
private sealed class Bucket
{
public double Tokens;
public DateTime LastRefill;
public readonly double Capacity;
public readonly double RefillPerSecond;
public Bucket(double capacity, double refillPerSecond)
{
Capacity = capacity;
RefillPerSecond = refillPerSecond;
Tokens = capacity;
LastRefill = DateTime.UtcNow;
}
/// Returns true and consumes a token if available.
public bool TryConsume()
{
DateTime now = DateTime.UtcNow;
double elapsed = (now - LastRefill).TotalSeconds;
Tokens = Math.Min(Capacity, Tokens + elapsed * RefillPerSecond);
LastRefill = now;
if (Tokens >= 1.0) { Tokens -= 1.0; return true; }
return false;
}
}
private sealed class DedupEntry
{
public int Count;
public DateTime FirstSeen;
public LogEvent LastEvent;
}
private readonly IFlow _inner;
private readonly int _burstCapacity;
private readonly double _refillPerSecond;
private readonly bool _deduplicate;
private readonly TimeSpan _dedupWindow;
private readonly int _dedupMaxKeys;
private readonly Dictionary<LogLevel, Bucket> _buckets
= new Dictionary<LogLevel, Bucket>();
private readonly Dictionary<string, DedupEntry> _dedupMap
= new Dictionary<string, DedupEntry>(StringComparer.Ordinal);
private readonly object _lock = new object();
private long _throttledCount;
/// <param name="inner">The downstream flow to protect.</param>
/// <param name="burstCapacity">
/// Max events that can be emitted in a burst per level (token bucket capacity).
/// </param>
/// <param name="refillPerSecond">
/// How many tokens are added per second per level. E.g. 5.0 = 5 events/second steady state.
/// </param>
/// <param name="deduplicate">
/// If true, identical messages within <paramref name="dedupWindow"/> are collapsed.
/// The suppressed count is appended to the message when the window expires.
/// </param>
/// <param name="dedupWindow">Deduplication window (default 60 s).</param>
/// <param name="dedupMaxKeys">Maximum number of distinct messages tracked (default 1000).</param>
/// <param name="minimumLevel">Minimum level this flow processes.</param>
public ThrottledFlow(
IFlow inner,
int burstCapacity = 10,
double refillPerSecond = 1.0,
bool deduplicate = false,
TimeSpan dedupWindow = default(TimeSpan),
int dedupMaxKeys = 1000,
LogLevel minimumLevel = LogLevel.Trace)
: base("Throttled:" + (inner != null ? inner.GetType().Name : "null"), minimumLevel)
{
if (inner == null)
{
throw new ArgumentNullException("inner");
}
_inner = inner;
_burstCapacity = burstCapacity < 1 ? 1 : burstCapacity;
_refillPerSecond = refillPerSecond <= 0 ? 1.0 : refillPerSecond;
_deduplicate = deduplicate;
_dedupWindow = dedupWindow == default(TimeSpan) ? TimeSpan.FromSeconds(60) : dedupWindow;
_dedupMaxKeys = dedupMaxKeys < 1 ? 1 : dedupMaxKeys;
// Pre-create buckets for all defined levels
foreach (LogLevel level in Enum.GetValues(typeof(LogLevel)))
{
_buckets[level] = new Bucket(_burstCapacity, _refillPerSecond);
}
}
/// <summary>Events throttled (dropped by rate limit or dedup) so far.</summary>
public long ThrottledCount { get { return Interlocked.Read(ref _throttledCount); } }
public override async Task<WriteResult> BlastAsync(
LogEvent logEvent,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return WriteResult.LevelFiltered;
}
lock (_lock)
{
// deduplication pass
if (_deduplicate)
{
string key = MakeDedupKey(logEvent);
DedupEntry entry;
// Flush expired entries to avoid unbounded growth
if (_dedupMap.Count >= _dedupMaxKeys)
{
PurgeExpiredDedupEntries();
}
if (_dedupMap.TryGetValue(key, out entry))
{
TimeSpan age = DateTime.UtcNow - entry.FirstSeen;
if (age < _dedupWindow)
{
entry.Count++;
entry.LastEvent = logEvent;
Interlocked.Increment(ref _throttledCount);
return WriteResult.Dropped;
}
else
{
// Window expired: flush the suppressed count as a synthetic event
if (entry.Count > 1)
{
FlushDedupEntry(key, entry);
}
_dedupMap.Remove(key);
}
}
// First occurrence
_dedupMap[key] = new DedupEntry
{
Count = 1,
FirstSeen = DateTime.UtcNow,
LastEvent = logEvent
};
}
// token bucket pass
Bucket bucket;
if (!_buckets.TryGetValue(logEvent.Level, out bucket))
{
bucket = new Bucket(_burstCapacity, _refillPerSecond);
_buckets[logEvent.Level] = bucket;
}
if (!bucket.TryConsume())
{
Interlocked.Increment(ref _throttledCount);
Interlocked.Increment(ref DroppedCount);
return WriteResult.Dropped;
}
}
WriteResult result = await _inner.BlastAsync(logEvent, cancellationToken).ConfigureAwait(false);
Interlocked.Increment(ref BlastedCount);
return result;
}
public override async Task<WriteResult> BlastBatchAsync(
ReadOnlyMemory<LogEvent> logEvents,
CancellationToken cancellationToken = default(CancellationToken))
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
WriteResult result = WriteResult.Success;
foreach (LogEvent e in logEvents.ToArray())
{
WriteResult r = await BlastAsync(e, cancellationToken).ConfigureAwait(false);
if (r == WriteResult.Dropped)
{
result = WriteResult.Dropped;
}
}
return result;
}
public override Task FlushAsync(CancellationToken cancellationToken = default(CancellationToken))
{
// Flush all pending dedup entries
lock (_lock)
{
List<string> keys = new List<string>(_dedupMap.Keys);
foreach (string key in keys)
{
DedupEntry entry;
if (_dedupMap.TryGetValue(key, out entry) && entry.Count > 1)
{
FlushDedupEntry(key, entry);
}
_dedupMap.Remove(key);
}
}
return _inner.FlushAsync(cancellationToken);
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
await FlushAsync().ConfigureAwait(false);
await base.DisposeAsync().ConfigureAwait(false);
}
private static string MakeDedupKey(LogEvent log)
{
// Key = level + category + first 200 chars of message (ignore dynamic parts like timestamps)
string msg = log.Message.Length > 0 ? log.Message.ToString() : string.Empty;
if (msg.Length > 200)
{
msg = msg.Substring(0, 200);
}
return log.Level + "|" + (log.Category ?? string.Empty) + "|" + msg;
}
private void FlushDedupEntry(string key, DedupEntry entry)
{
// Build a synthetic event that summarises the suppressed repeats
string original = entry.LastEvent.Message.Length > 0
? entry.LastEvent.Message.ToString()
: string.Empty;
string summary = original + " [repeated " + (entry.Count - 1) + " more times in "
+ (int)_dedupWindow.TotalSeconds + "s window]";
LogEvent synth = new LogEvent
{
Level = entry.LastEvent.Level,
Category = entry.LastEvent.Category,
Timestamp = entry.LastEvent.Timestamp,
Message = new StringSegment(summary),
Exception = entry.LastEvent.Exception
};
try { _inner.BlastAsync(synth).GetAwaiter().GetResult(); }
catch { /* best-effort */ }
}
private void PurgeExpiredDedupEntries()
{
List<string> expired = new List<string>();
DateTime cutoff = DateTime.UtcNow - _dedupWindow;
foreach (KeyValuePair<string, DedupEntry> kv in _dedupMap)
{
if (kv.Value.FirstSeen < cutoff)
{
expired.Add(kv.Key);
}
}
foreach (string k in expired)
{
DedupEntry entry;
if (_dedupMap.TryGetValue(k, out entry) && entry.Count > 1)
{
FlushDedupEntry(k, entry);
}
_dedupMap.Remove(k);
}
}
}
}
@@ -0,0 +1,207 @@
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class UdpFlow : FlowBase
{
private readonly int _batchSize;
private const int ChannelCapacity = 4096;
private readonly Channel<LogEvent> _channel;
private readonly Task _senderTask;
private readonly CancellationTokenSource _cts;
private readonly string _host;
private readonly int _port;
private readonly UdpClient _udpClient;
private readonly BackpressureStrategy _backpressureStrategy;
private readonly TimeSpan _flushInterval;
private readonly Task _flushTask;
public UdpFlow(
string host,
int port,
int flushIntervalInMilliseconds = 2000,
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace,
BackpressureStrategy backpressureStrategy = BackpressureStrategy.DropOldest)
: base($"UDP:{host}:{port}", minimumLevel)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_port = port;
_backpressureStrategy = backpressureStrategy;
_flushInterval = TimeSpan.FromMilliseconds(flushIntervalInMilliseconds);
_batchSize = batchSize <= 0 ? 1 : batchSize;
_udpClient = new UdpClient();
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = backpressureStrategy switch
{
BackpressureStrategy.Wait => BoundedChannelFullMode.Wait,
BackpressureStrategy.DropNewest => BoundedChannelFullMode.DropWrite,
BackpressureStrategy.DropOldest => BoundedChannelFullMode.DropOldest,
_ => BoundedChannelFullMode.Wait
},
SingleReader = true,
SingleWriter = false
};
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_senderTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
if (flushIntervalInMilliseconds > 0)
{
_flushTask = Task.Run(() => PeriodicFlushAsync(_cts.Token));
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
var result = WriteResult.Success;
foreach (var logEvent in logEvents.Span)
{
if (!IsLogLevelEnabled(logEvent))
{
continue;
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
}
else
{
Interlocked.Increment(ref DroppedCount);
result = WriteResult.Dropped;
}
}
return result;
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
var sb = new StringBuilder(8192);
try
{
await foreach (var logEvent in _channel.Reader.ReadAllAsync(cancellationToken))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize || _channel.Reader.Count == 0)
{
await SendBatchAsync(batch, sb, cancellationToken);
batch.Clear();
sb.Clear();
}
}
if (batch.Count > 0)
{
await SendBatchAsync(batch, sb, CancellationToken.None);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Console.Error.WriteLine($"UdpFlow error: {ex.Message}");
}
}
private async Task SendBatchAsync(List<LogEvent> batch, StringBuilder sb, CancellationToken cancellationToken)
{
foreach (var logEvent in batch)
{
FormatLogEvent(logEvent, sb);
sb.AppendLine();
}
var data = Encoding.UTF8.GetBytes(sb.ToString());
await _udpClient.SendAsync(data, data.Length, _host, _port);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void FormatLogEvent(LogEvent logEvent, StringBuilder sb)
{
var dt = LogEvent.GetDateTime(logEvent.Timestamp);
sb.Append(dt.ToString("yyyy-MM-dd HH:mm:ss.fff"));
sb.Append(" [");
sb.Append(logEvent.Level.ToString().ToUpperInvariant());
sb.Append("] ");
if (!string.IsNullOrEmpty(logEvent.Category))
{
sb.Append(logEvent.Category);
sb.Append(": ");
}
sb.Append(logEvent.Message);
}
private async Task PeriodicFlushAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
await Task.Delay(_flushInterval, token);
}
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try { await _senderTask.ConfigureAwait(false); } catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try { await _senderTask.ConfigureAwait(false); } catch { }
_udpClient.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
@@ -0,0 +1,85 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public class WebhookFlow : FlowBase
{
private readonly string _webhookUrl;
private readonly HttpClient _httpClient;
private readonly int _maxRetries;
private readonly TimeSpan _retryDelay;
public WebhookFlow(string webhookUrl, LogLevel minimumLevel = LogLevel.Trace, int maxRetries = 3, TimeSpan? retryDelay = null) : base($"Webhook:{webhookUrl}", minimumLevel)
{
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
_httpClient = new HttpClient();
_maxRetries = maxRetries;
_retryDelay = retryDelay ?? TimeSpan.FromSeconds(1);
}
public override async Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return WriteResult.LevelFiltered;
}
var logPayload = new
{
Timestamp = LogEvent.GetDateTime(logEvent.Timestamp),
Level = logEvent.Level.ToString(),
Message = logEvent.Message,
Category = logEvent.Category,
LogEvent = logEvent
};
var jsonPayload = JsonHelper.ToJson(logPayload);
var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
int attempt = 0;
while (attempt < _maxRetries)
{
try
{
var response = await _httpClient.PostAsync(_webhookUrl, content, cancellationToken);
if (response.IsSuccessStatusCode)
{
return WriteResult.Success;
}
attempt++;
await Task.Delay(_retryDelay, cancellationToken);
}
catch (Exception ex)
{
Console.Error.WriteLine($"WebhookFlow error: {ex.Message}");
attempt++;
await Task.Delay(_retryDelay, cancellationToken);
}
}
return WriteResult.Dropped;
}
public override async ValueTask DisposeAsync()
{
_httpClient.Dispose();
await base.DisposeAsync();
}
public override Task FlushAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}
}
@@ -0,0 +1,235 @@
using EonaCat.Json;
using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
public sealed class ZabbixFlow : FlowBase
{
private readonly int _batchSize;
private const int ChannelCapacity = 4096;
private readonly Channel<LogEvent> _channel;
private readonly Task _senderTask;
private readonly CancellationTokenSource _cts;
private readonly string _host;
private readonly int _port;
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private readonly BackpressureStrategy _backpressureStrategy;
private readonly string _zabbixHostName;
private readonly string _zabbixKey;
public ZabbixFlow(
string host,
int port = 10051,
string zabbixHostName = null,
string zabbixKey = "log_event",
int batchSize = 1,
LogLevel minimumLevel = LogLevel.Trace,
BackpressureStrategy backpressureStrategy = BackpressureStrategy.DropOldest)
: base($"Zabbix:{host}:{port}", minimumLevel)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_port = port;
_backpressureStrategy = backpressureStrategy;
_zabbixHostName = zabbixHostName ?? Environment.MachineName;
_zabbixKey = zabbixKey ?? "log_event";
var channelOptions = new BoundedChannelOptions(ChannelCapacity)
{
FullMode = backpressureStrategy switch
{
BackpressureStrategy.Wait => BoundedChannelFullMode.Wait,
BackpressureStrategy.DropNewest => BoundedChannelFullMode.DropWrite,
BackpressureStrategy.DropOldest => BoundedChannelFullMode.DropOldest,
_ => BoundedChannelFullMode.Wait
},
SingleReader = true,
SingleWriter = false
};
_batchSize = batchSize <= 0 ? 1 : batchSize;
_channel = Channel.CreateBounded<LogEvent>(channelOptions);
_cts = new CancellationTokenSource();
_senderTask = Task.Run(() => ProcessLogEventsAsync(_cts.Token));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default)
{
if (!IsEnabled || !IsLogLevelEnabled(logEvent))
{
return Task.FromResult(WriteResult.LevelFiltered);
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
return Task.FromResult(WriteResult.Success);
}
Interlocked.Increment(ref DroppedCount);
return Task.FromResult(WriteResult.Dropped);
}
public override async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
if (!IsEnabled)
{
return WriteResult.FlowDisabled;
}
var result = WriteResult.Success;
foreach (var logEvent in logEvents.Span)
{
if (!IsLogLevelEnabled(logEvent))
{
continue;
}
if (_channel.Writer.TryWrite(logEvent))
{
Interlocked.Increment(ref BlastedCount);
}
else
{
Interlocked.Increment(ref DroppedCount);
result = WriteResult.Dropped;
}
}
return result;
}
private async Task ProcessLogEventsAsync(CancellationToken cancellationToken)
{
var batch = new List<LogEvent>(_batchSize);
while (!cancellationToken.IsCancellationRequested)
{
try
{
await EnsureConnectedAsync(cancellationToken);
await foreach (var logEvent in _channel.Reader.ReadAllAsync(cancellationToken))
{
batch.Add(logEvent);
if (batch.Count >= _batchSize || _channel.Reader.Count == 0)
{
await SendBatchAsync(batch, cancellationToken);
batch.Clear();
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"ZabbixFlow error: {ex.Message}");
await Task.Delay(1000, cancellationToken);
_tcpClient?.Dispose();
_tcpClient = null;
}
}
}
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
if (_tcpClient != null && _tcpClient.Connected)
{
return;
}
_tcpClient?.Dispose();
_tcpClient = new TcpClient();
await _tcpClient.ConnectAsync(_host, _port);
_stream = _tcpClient.GetStream();
}
private async Task SendBatchAsync(List<LogEvent> batch, CancellationToken cancellationToken)
{
if (_stream == null || batch.Count == 0)
{
return;
}
foreach (var logEvent in batch)
{
var payload = new
{
request = "sender data",
data = new[]
{
new {
host = _zabbixHostName,
key = _zabbixKey,
value = FormatLogEvent(logEvent)
}
}
};
string json = JsonHelper.ToJson(payload);
byte[] jsonBytes = Encoding.UTF8.GetBytes(json);
// Zabbix protocol header
byte[] header = new byte[13]; // "ZBXD\1" + 8 bytes length
header[0] = (byte)'Z';
header[1] = (byte)'B';
header[2] = (byte)'X';
header[3] = (byte)'D';
header[4] = 1;
long length = jsonBytes.Length;
for (int i = 0; i < 8; i++)
{
header[5 + i] = (byte)(length >> (8 * i) & 0xFF);
}
await _stream.WriteAsync(header, 0, header.Length, cancellationToken);
await _stream.WriteAsync(jsonBytes, 0, jsonBytes.Length, cancellationToken);
await _stream.FlushAsync(cancellationToken);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private string FormatLogEvent(LogEvent logEvent)
{
var dt = LogEvent.GetDateTime(logEvent.Timestamp);
string ts = dt.ToString("yyyy-MM-dd HH:mm:ss.fff");
string category = string.IsNullOrEmpty(logEvent.Category) ? "ZabbixFlow" : logEvent.Category;
return $"{ts} [{logEvent.Level}] {category}: {logEvent.Message}";
}
public override async Task FlushAsync(CancellationToken cancellationToken = default)
{
_channel.Writer.Complete();
try { await _senderTask.ConfigureAwait(false); } catch { }
}
public override async ValueTask DisposeAsync()
{
IsEnabled = false;
_channel.Writer.Complete();
_cts.Cancel();
try { await _senderTask.ConfigureAwait(false); } catch { }
_stream?.Dispose();
_tcpClient?.Dispose();
_cts.Dispose();
await base.DisposeAsync();
}
}
}
@@ -0,0 +1,26 @@
using EonaCat.LogStack.Core;
namespace EonaCat.LogStack.Boosters;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Boosters enrich log events with additional context or transform them before they reach flows.
/// Boosters are designed for zero-allocation where possible.
/// </summary>
public interface IBooster
{
/// <summary>
/// Gets the name of this booster for identification
/// </summary>
string Name { get; }
/// <summary>
/// Boost a log event with additional data or transforms it.
/// Return false to filter out the event entirely.
/// </summary>
/// <param name="builder">Builder to modify the log event</param>
/// <returns>True to continue processing, false to filter out the event</returns>
bool Boost(ref LogEventBuilder builder);
}
+126
View File
@@ -0,0 +1,126 @@
using EonaCat.LogStack.Core;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace EonaCat.LogStack.Flows;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Flows are output destinations for log events (replacement for "sinks").
/// Each flow handles writing log events to a specific destination with optimized batching.
/// </summary>
public interface IFlow : IAsyncDisposable
{
/// <summary>
/// Gets the name of this flow for identification
/// </summary>
string Name { get; }
/// <summary>
/// Minimum log level this flow will process
/// </summary>
LogLevel MinimumLevel { get; }
/// <summary>
/// Whether this flow is currently enabled
/// </summary>
bool IsEnabled { get; }
/// <summary>
/// Blast a single log event to this flow
/// </summary>
Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default);
/// <summary>
/// Blast a batch of log events to this flow (more efficient than single blasts)
/// </summary>
Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default);
/// <summary>
/// Flush any buffered log events immediately
/// </summary>
Task FlushAsync(CancellationToken cancellationToken = default);
}
/// <summary>
/// Base class for flows with common functionality
/// </summary>
public abstract class FlowBase : IFlow
{
protected FlowBase(string name, LogLevel minimumLevel = LogLevel.Trace)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
MinimumLevel = minimumLevel;
IsEnabled = true;
}
public string Name { get; }
public LogLevel MinimumLevel { get; protected set; }
public bool IsEnabled { get; protected set; }
protected long DroppedCount;
protected long BlastedCount;
protected bool IsLogLevelEnabled(LogEvent logEvent)
{
return logEvent.Level >= MinimumLevel;
}
public abstract Task<WriteResult> BlastAsync(LogEvent logEvent, CancellationToken cancellationToken = default);
public virtual async Task<WriteResult> BlastBatchAsync(ReadOnlyMemory<LogEvent> logEvents, CancellationToken cancellationToken = default)
{
var result = WriteResult.Success;
var eventsArray = logEvents.ToArray();
foreach (var logEvent in eventsArray)
{
var singleResult = await BlastAsync(logEvent, cancellationToken).ConfigureAwait(false);
if (singleResult != WriteResult.Success)
{
result = singleResult;
}
}
return result;
}
public abstract Task FlushAsync(CancellationToken cancellationToken = default);
public virtual async ValueTask DisposeAsync()
{
IsEnabled = false;
await FlushAsync(default).ConfigureAwait(false);
GC.SuppressFinalize(this);
}
/// <summary>
/// Gets diagnostic information about this flow
/// </summary>
public virtual FlowDiagnostics GetDiagnostics()
{
return new FlowDiagnostics
{
Name = Name,
IsEnabled = IsEnabled,
MinimumLevel = MinimumLevel,
BlastedCount = Interlocked.Read(ref BlastedCount),
DroppedCount = Interlocked.Read(ref DroppedCount)
};
}
}
/// <summary>
/// Diagnostic information about a flow
/// </summary>
public sealed class FlowDiagnostics
{
public string Name { get; set; }
public bool IsEnabled { get; set; }
public LogLevel MinimumLevel { get; set; }
public long BlastedCount { get; set; }
public long DroppedCount { get; set; }
}
@@ -0,0 +1,169 @@
using EonaCat.LogStack.Extensions;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace EonaCat.LogStack.Core;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Represents a single log event with efficient memory management through pooling.
/// This struct is designed to minimize allocations and support high-throughput logging.
/// </summary>
public struct LogEvent
{
public long Timestamp { get; set; }
public LogLevel Level { get; set; }
public string Category { get; set; }
public ReadOnlyMemory<char> Message { get; set; }
public Exception? Exception { get; set; }
public Dictionary<string, object?> Properties { get; set; }
public string CustomData { get; set; }
public int ThreadId { get; set; }
public ActivityTraceId TraceId { get; set; }
public ActivitySpanId SpanId { get; set; }
/// <summary>
/// Estimated memory size in bytes for backpressure calculations
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int EstimateSize()
{
// Base overhead
int size = 64;
// Message size
size += Message.Length * 2;
// Category size
size += (Category?.Length ?? 0) * 2;
// Exception size (estimated)
if (Exception != null)
{
size += 512;
}
// Properties size
size += Properties.Count * 32;
return size;
}
/// <summary>
/// Creates a timestamp value from DateTime
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static long CreateTimestamp(DateTime dateTime) => dateTime.Ticks;
/// <summary>
/// Converts timestamp back to DateTime
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static DateTime GetDateTime(long timestamp) => new(timestamp);
public bool HasProperties => Properties != null && Properties.Count > 0;
public bool HasCustomData => CustomData != null && CustomData.Length > 0;
public bool HasException => Exception != null;
public bool HasCategory => Category != null;
}
/// <summary>
/// Builder for creating LogEvent instances with minimal allocations
/// </summary>
public struct LogEventBuilder
{
private long _timestamp;
private LogLevel _level;
private string? _category;
private ReadOnlyMemory<char> _message;
private Exception? _exception;
private Dictionary<string, object>? _properties;
private int _threadId;
private ActivityTraceId _traceId;
private ActivitySpanId _spanId;
public LogEventBuilder()
{
_timestamp = DateTime.UtcNow.Ticks;
_level = LogLevel.Information;
_threadId = Environment.CurrentManagedThreadId;
var activity = Activity.Current;
_traceId = activity?.TraceId ?? default;
_spanId = activity?.SpanId ?? default;
}
public string? Category => _category;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEventBuilder WithTimestamp(long timestamp)
{
_timestamp = timestamp;
return this;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEventBuilder WithLevel(LogLevel level)
{
_level = level;
return this;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEventBuilder WithCategory(string category)
{
_category = category;
return this;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEventBuilder WithMessage(ReadOnlyMemory<char> message)
{
_message = message;
return this;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEventBuilder WithMessage(string message)
{
_message = message.AsMemory();
return this;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEventBuilder WithException(Exception? exception)
{
_exception = exception;
return this;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEventBuilder WithProperty(string key, object? value)
{
_properties ??= new Dictionary<string, object>(4);
_properties.TryAdd(key, value);
return this;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public LogEvent Build()
{
return new LogEvent
{
Timestamp = _timestamp,
Level = _level,
Category = _category ?? string.Empty,
Message = _message,
Exception = _exception,
Properties = _properties ?? new Dictionary<string, object>(),
ThreadId = _threadId,
TraceId = _traceId,
SpanId = _spanId
};
}
}
@@ -0,0 +1,23 @@
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
namespace EonaCat.LogStack.EonaCatLogStackCore
{
public struct LogStats
{
public long Written;
public long Dropped;
public long Rotations;
public long BytesWritten;
public double WritesPerSecond;
public LogStats(long written, long dropped, long rotations, long bytesWritten, double writesPerSecond)
{
Written = written;
Dropped = dropped;
Rotations = rotations;
BytesWritten = bytesWritten;
WritesPerSecond = writesPerSecond;
}
}
}
@@ -0,0 +1,25 @@
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
namespace EonaCat.LogStack.EonaCatLogStackCore.Policies
{
/// <summary>Combined retention policy: delete rolled files exceeding any threshold.</summary>
public sealed class FileRetentionPolicy
{
/// <summary>Maximum number of rolled archive files to keep (0 = unlimited).</summary>
public int MaxRolledFiles { get; set; }
/// <summary>Maximum total size of all archives in bytes (0 = unlimited).</summary>
public long MaxTotalArchiveBytes { get; set; }
/// <summary>Maximum age of any archive file in days (0 = unlimited).</summary>
public int MaxAgeDays { get; set; }
public FileRetentionPolicy()
{
MaxRolledFiles = 10;
MaxTotalArchiveBytes = 0;
MaxAgeDays = 0;
}
}
}
@@ -0,0 +1,39 @@
using EonaCat.LogStack.Core;
using System;
using System.Runtime.CompilerServices;
using System.Threading;
namespace EonaCat.LogStack.EonaCatLogStackCore.Policies
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>Log only 1-in-N events, optionally filtered by a predicate.</summary>
public sealed class SamplingPolicy
{
private long _counter;
/// <summary>Keep 1 out of every <see cref="Rate"/> events.</summary>
public int Rate { get; set; }
/// <summary>Optional predicate. Null = apply to all events.</summary>
public Func<LogEvent, bool> Predicate { get; set; }
public SamplingPolicy()
{
Rate = 10;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ShouldLog(LogEvent e)
{
if (Predicate != null && !Predicate(e))
{
// predicate not matched → always log
return true;
}
return Interlocked.Increment(ref _counter) % Rate == 0;
}
}
}
@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using System.Text;
namespace EonaCat.LogStack.EonaCatLogStackCore
{
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
internal static class StringBuilderPool
{
private static readonly ConcurrentBag<StringBuilder> Pool = new ConcurrentBag<StringBuilder>();
private const int InitialCapacity = 4096;
private const int MaxCapacity = 131072; // 128 KB discard oversized builders
public static StringBuilder Rent()
{
StringBuilder sb;
if (Pool.TryTake(out sb))
{
sb.Clear();
return sb;
}
return new StringBuilder(InitialCapacity);
}
public static void Return(StringBuilder sb)
{
if (sb.Capacity <= MaxCapacity)
{
sb.Clear();
Pool.Add(sb);
}
}
}
}