Initial version
This commit is contained in:
@@ -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("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user