first commit
This commit is contained in:
55
NitroxModel/Logger/ColoredConsoleSink.cs
Normal file
55
NitroxModel/Logger/ColoredConsoleSink.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Serilog;
|
||||
using Serilog.Configuration;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting;
|
||||
using Serilog.Formatting.Display;
|
||||
|
||||
namespace NitroxModel.Logger
|
||||
{
|
||||
public class ColoredConsoleSink : ILogEventSink
|
||||
{
|
||||
private readonly ConsoleColor defaultBackground = Console.BackgroundColor;
|
||||
private readonly ConsoleColor defaultForeground = Console.ForegroundColor;
|
||||
|
||||
private readonly ITextFormatter formatter;
|
||||
|
||||
public ColoredConsoleSink(ITextFormatter formatter)
|
||||
{
|
||||
this.formatter = formatter;
|
||||
}
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
(Console.ForegroundColor, Console.BackgroundColor) = logEvent.Level switch
|
||||
{
|
||||
LogEventLevel.Verbose => (ConsoleColor.DarkGray, defaultBackground),
|
||||
LogEventLevel.Debug => (ConsoleColor.DarkGray, defaultBackground),
|
||||
LogEventLevel.Information => (ConsoleColor.Gray, defaultBackground),
|
||||
LogEventLevel.Warning => (ConsoleColor.Yellow, defaultBackground),
|
||||
LogEventLevel.Error => (ConsoleColor.Red, defaultBackground),
|
||||
LogEventLevel.Fatal => (ConsoleColor.Red, ConsoleColor.Yellow),
|
||||
_ => (defaultForeground, defaultBackground)
|
||||
};
|
||||
|
||||
formatter.Format(logEvent, Console.Out);
|
||||
Console.Out.Flush();
|
||||
|
||||
Console.ForegroundColor = defaultForeground;
|
||||
Console.BackgroundColor = defaultBackground;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ColoredConsoleSinkExtensions
|
||||
{
|
||||
public static LoggerConfiguration ColoredConsole(
|
||||
this LoggerSinkConfiguration loggerConfiguration,
|
||||
LogEventLevel minimumLevel = LogEventLevel.Verbose,
|
||||
string outputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}",
|
||||
IFormatProvider formatProvider = null)
|
||||
{
|
||||
return loggerConfiguration.Sink(new ColoredConsoleSink(new MessageTemplateTextFormatter(outputTemplate, formatProvider)), minimumLevel);
|
||||
}
|
||||
}
|
||||
}
|
42
NitroxModel/Logger/ConditionalValveSink.cs
Normal file
42
NitroxModel/Logger/ConditionalValveSink.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting;
|
||||
|
||||
namespace NitroxModel.Logger;
|
||||
|
||||
public class ConditionalValveSink : ILogEventSink
|
||||
{
|
||||
private readonly Func<LogEvent, bool> thresholdPredicate;
|
||||
private readonly ILogEventSink wrappedSink;
|
||||
private readonly Queue<LogEvent> queue = new();
|
||||
|
||||
public ConditionalValveSink(Func<LogEvent, bool> thresholdPredicate, ILogEventSink wrappedSink)
|
||||
{
|
||||
this.thresholdPredicate = thresholdPredicate;
|
||||
this.wrappedSink = wrappedSink;
|
||||
}
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
if (thresholdPredicate?.Invoke(logEvent) == true)
|
||||
{
|
||||
while (queue.Count > 0 && queue.Dequeue() is { } dequeuedEvent)
|
||||
{
|
||||
foreach (KeyValuePair<string,LogEventPropertyValue> pair in logEvent.Properties)
|
||||
{
|
||||
dequeuedEvent.AddOrUpdateProperty(new LogEventProperty(pair.Key, pair.Value));
|
||||
}
|
||||
wrappedSink.Emit(dequeuedEvent);
|
||||
}
|
||||
|
||||
wrappedSink.Emit(logEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
queue.Enqueue(logEvent);
|
||||
}
|
||||
}
|
||||
}
|
8
NitroxModel/Logger/InGameLogger.cs
Normal file
8
NitroxModel/Logger/InGameLogger.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace NitroxModel.Logger
|
||||
{
|
||||
public interface InGameLogger
|
||||
{
|
||||
void Log(object message);
|
||||
void Log(string message);
|
||||
}
|
||||
}
|
30
NitroxModel/Logger/LiteNetLibLogger.cs
Normal file
30
NitroxModel/Logger/LiteNetLibLogger.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using LiteNetLib;
|
||||
|
||||
namespace NitroxModel.Logger;
|
||||
|
||||
public class LiteNetLibLogger : INetLogger
|
||||
{
|
||||
public void WriteNet(NetLogLevel level, string str, params object[] args)
|
||||
{
|
||||
string message = $"[LiteNetLib] {string.Format(str, args)}";
|
||||
|
||||
switch (level)
|
||||
{
|
||||
case NetLogLevel.Error:
|
||||
Log.Error(message);
|
||||
break;
|
||||
case NetLogLevel.Warning:
|
||||
Log.Warn(message);
|
||||
break;
|
||||
case NetLogLevel.Info:
|
||||
Log.Info(message);
|
||||
break;
|
||||
case NetLogLevel.Trace:
|
||||
Log.Debug(message);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(level), level, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
411
NitroxModel/Logger/Log.cs
Normal file
411
NitroxModel/Logger/Log.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
global using NitroxModel.Logger;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using LiteNetLib;
|
||||
using NitroxModel.Helper;
|
||||
using Serilog;
|
||||
using Serilog.Configuration;
|
||||
using Serilog.Context;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace NitroxModel.Logger
|
||||
{
|
||||
public static class Log
|
||||
{
|
||||
private static ILogger logger = Serilog.Core.Logger.None;
|
||||
private static ILogger inGameLogger = Serilog.Core.Logger.None;
|
||||
private static readonly HashSet<int> logOnceCache = new();
|
||||
private static bool isSetup;
|
||||
private static string logFileName;
|
||||
|
||||
private static readonly object playerNameLock = new();
|
||||
private static string playerName = "";
|
||||
public static string PlayerName
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (playerNameLock)
|
||||
{
|
||||
return playerName;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
lock (playerNameLock)
|
||||
{
|
||||
playerName = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
lock (playerNameLock)
|
||||
{
|
||||
playerName = $"[{value}]";
|
||||
}
|
||||
|
||||
if (logger != null)
|
||||
{
|
||||
Info($"Setting player name to {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly object saveNameLock = new();
|
||||
private static string saveName = "";
|
||||
public static string SaveName
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (saveNameLock)
|
||||
{
|
||||
return saveName;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
lock (saveNameLock)
|
||||
{
|
||||
saveName = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (saveNameLock)
|
||||
{
|
||||
saveName = $"[{value}]";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string LogDirectory { get; } = Path.GetFullPath(Path.Combine(NitroxUser.AppDataPath ?? "", "Logs"));
|
||||
|
||||
public static string GetMostRecentLogFile() => new DirectoryInfo(LogDirectory).GetFiles().OrderByDescending(f => f.CreationTimeUtc).FirstOrDefault()?.FullName; // TODO: Filter by servername ( .Where(f => f.Name.Contains($"[{SaveName}]")) )
|
||||
|
||||
public static void Setup(bool asyncConsoleWriter = false, InGameLogger gameLogger = null, bool isConsoleApp = false, bool useConsoleLogging = true, bool useFileLogging = true)
|
||||
{
|
||||
if (isSetup)
|
||||
{
|
||||
Warn($"{nameof(Log)} setup should only be executed once.");
|
||||
return;
|
||||
}
|
||||
isSetup = true;
|
||||
NetDebug.Logger = new LiteNetLibLogger();
|
||||
|
||||
PlayerName = "";
|
||||
SaveName = "";
|
||||
|
||||
// Configure logger and create an instance of it.
|
||||
LoggerConfiguration loggerConfig = new LoggerConfiguration().MinimumLevel.Debug().Enrich.With<NitroxPropEnricher>();
|
||||
if (useConsoleLogging)
|
||||
{
|
||||
loggerConfig = loggerConfig.WriteTo.AppendConsoleSink(asyncConsoleWriter, isConsoleApp);
|
||||
}
|
||||
if (useFileLogging)
|
||||
{
|
||||
// Wrap sink into new logger with enriching (which will redact sensitive properties only for file logs)
|
||||
loggerConfig = loggerConfig.WriteTo.Logger(l =>
|
||||
{
|
||||
l.Enrich.FromLogContext()
|
||||
.WriteTo.AppendFileSink();
|
||||
});
|
||||
}
|
||||
logger = loggerConfig.CreateLogger();
|
||||
|
||||
if (gameLogger != null)
|
||||
{
|
||||
inGameLogger = new LoggerConfiguration()
|
||||
.WriteTo.Logger(cnf => cnf.WriteTo.Message(gameLogger.Log))
|
||||
.CreateLogger();
|
||||
}
|
||||
}
|
||||
|
||||
private static LoggerConfiguration AppendFileSink(this LoggerSinkConfiguration sinkConfig) => sinkConfig.Logger(cnf =>
|
||||
{
|
||||
static bool LogEventHasPropertiesAny(LogEvent @event, params string[] propertyKeys)
|
||||
{
|
||||
foreach (string key in propertyKeys)
|
||||
{
|
||||
if (!@event.Properties.TryGetValue(key, out LogEventPropertyValue propValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string propValueStr = propValue.ToString().Trim('\"'); // ToString of Serilog properties returns \"\" when empty string.
|
||||
if (string.IsNullOrWhiteSpace(propValueStr))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cnf.WriteTo
|
||||
.Valve(v =>
|
||||
{
|
||||
v.Async(a =>
|
||||
{
|
||||
a.Map(nameof(SaveName), "", (saveName, m) =>
|
||||
{
|
||||
m.Map(nameof(PlayerName), "", (playerName, m2) =>
|
||||
{
|
||||
m2.File(Path.Combine(LogDirectory, $"{GetLogFileName()}{saveName}{playerName}-.log"),
|
||||
outputTemplate: "[{Timestamp:HH:mm:ss.fff}] [{Level:u3}{IsUnity}] {Message}{NewLine}{Exception}",
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 10,
|
||||
fileSizeLimitBytes: 200_000_000, // 200MB
|
||||
shared: true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}, e => LogEventHasPropertiesAny(e, nameof(SaveName), nameof(PlayerName)) || GetLogFileName() is "launcher");
|
||||
});
|
||||
|
||||
private static LoggerConfiguration AppendConsoleSink(this LoggerSinkConfiguration sinkConfig, bool makeAsync, bool useShorterTemplate) => sinkConfig.Logger(cnf =>
|
||||
{
|
||||
string consoleTemplate = useShorterTemplate switch
|
||||
{
|
||||
false => $"[{{Timestamp:HH:mm:ss.fff}}] {{{nameof(PlayerName)}:l}}[{{Level:u3}}] {{Message}}{{NewLine}}{{Exception}}",
|
||||
_ => "[{Timestamp:HH:mm:ss.fff}] {Message}{NewLine}{Exception}"
|
||||
};
|
||||
|
||||
if (makeAsync)
|
||||
{
|
||||
cnf.WriteTo.Async(a => a.ColoredConsole(outputTemplate: consoleTemplate));
|
||||
}
|
||||
else
|
||||
{
|
||||
cnf.WriteTo.ColoredConsole(outputTemplate: consoleTemplate);
|
||||
}
|
||||
});
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
public static void Debug(string message)
|
||||
{
|
||||
logger.Debug(message);
|
||||
}
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
public static void Debug(object message)
|
||||
{
|
||||
Debug(message?.ToString());
|
||||
}
|
||||
|
||||
public static void Info(string message)
|
||||
{
|
||||
logger.Information(message);
|
||||
}
|
||||
|
||||
public static void Info(object message)
|
||||
{
|
||||
Info(message?.ToString());
|
||||
}
|
||||
|
||||
public static void Warn(string message)
|
||||
{
|
||||
logger.Warning(message);
|
||||
}
|
||||
|
||||
public static void Warn(object message)
|
||||
{
|
||||
Warn(message?.ToString());
|
||||
}
|
||||
|
||||
public static void Error(Exception ex)
|
||||
{
|
||||
logger.Error(ex, ex.Message);
|
||||
}
|
||||
|
||||
public static void Error(Exception ex, string message)
|
||||
{
|
||||
logger.Error(ex, message);
|
||||
}
|
||||
|
||||
public static void Error(string message)
|
||||
{
|
||||
logger.Error(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only logs the message one time. The messages must be the same for this function to work.
|
||||
/// </summary>
|
||||
public static void WarnOnce(string message)
|
||||
{
|
||||
int hash = message?.GetHashCode() ?? 0;
|
||||
if (logOnceCache.Contains(hash))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Warn(message);
|
||||
logOnceCache.Add(hash);
|
||||
}
|
||||
|
||||
public static void ErrorOnce(string message)
|
||||
{
|
||||
int hash = message?.GetHashCode() ?? 0;
|
||||
if (logOnceCache.Add(hash))
|
||||
{
|
||||
Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Verbose(string message)
|
||||
{
|
||||
Write(LogLevel.Verbose, message);
|
||||
}
|
||||
|
||||
public static void InGame(string message)
|
||||
{
|
||||
inGameLogger.Information(message);
|
||||
}
|
||||
|
||||
public static void Write(LogLevel level, string message)
|
||||
{
|
||||
logger.Write((LogEventLevel)level, message);
|
||||
}
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
public static void DebugSensitive(string message, params object[] args)
|
||||
{
|
||||
using (LogContext.Push(SensitiveEnricher.Instance))
|
||||
{
|
||||
logger.Debug(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public static void InfoSensitive(string message, params object[] args)
|
||||
{
|
||||
using (LogContext.Push(SensitiveEnricher.Instance))
|
||||
{
|
||||
logger.Information(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public static void WarnSensitive(string message, params object[] args)
|
||||
{
|
||||
using (LogContext.Push(SensitiveEnricher.Instance))
|
||||
{
|
||||
logger.Warning(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ErrorSensitive(Exception ex, string message, params object[] args)
|
||||
{
|
||||
using (LogContext.Push(SensitiveEnricher.Instance))
|
||||
{
|
||||
logger.Error(ex, message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ErrorSensitive(string message, params object[] args)
|
||||
{
|
||||
using (LogContext.Push(SensitiveEnricher.Instance))
|
||||
{
|
||||
logger.Error(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ErrorUnity(string message)
|
||||
{
|
||||
using (LogContext.PushProperty("IsUnity", "-UNITY"))
|
||||
{
|
||||
logger.Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get log file friendly name of the application that is currently logging.
|
||||
/// </summary>
|
||||
/// <returns>Friendly display name of the current application.</returns>
|
||||
private static string GetLoggerName()
|
||||
{
|
||||
string name = Assembly.GetEntryAssembly()?.GetName().Name ?? "game"; // Unity Engine does not set Assembly name
|
||||
return name.IndexOf("server", StringComparison.InvariantCultureIgnoreCase) >= 0 ? "server" : name;
|
||||
}
|
||||
|
||||
private static string GetLogFileName()
|
||||
{
|
||||
static bool Contains(string haystack, string needle) => haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
return logFileName ??= GetLoggerName() switch
|
||||
{
|
||||
{ } s when Contains(s, "server") => "server",
|
||||
{ } s when Contains(s, "launch") => "launcher",
|
||||
_ => "game"
|
||||
};
|
||||
}
|
||||
|
||||
private class SensitiveEnricher : ILogEventEnricher
|
||||
{
|
||||
/// <summary>
|
||||
/// Parameters that are being logged with these names should be excluded when a log was made through the sensitive
|
||||
/// method calls.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> sensitiveLogParameters =
|
||||
[
|
||||
"username",
|
||||
"password",
|
||||
"ip",
|
||||
"hostname",
|
||||
"path"
|
||||
];
|
||||
|
||||
private static readonly Lazy<SensitiveEnricher> instance = new(() => new SensitiveEnricher(), LazyThreadSafetyMode.PublicationOnly);
|
||||
public static SensitiveEnricher Instance => instance.Value;
|
||||
|
||||
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propFactory)
|
||||
{
|
||||
foreach ((string key, string value) prop in GetPropertiesAsRedacted(logEvent.Properties))
|
||||
{
|
||||
logEvent.AddOrUpdateProperty(propFactory.CreateProperty(prop.key, prop.value));
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<(string key, string value)> GetPropertiesAsRedacted(IEnumerable<KeyValuePair<string, LogEventPropertyValue>> originalProps)
|
||||
{
|
||||
foreach (KeyValuePair<string, LogEventPropertyValue> prop in originalProps)
|
||||
{
|
||||
if (!sensitiveLogParameters.Contains(prop.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return (prop.Key, new string('*', prop.Value.ToString().Length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Property enricher to be used instead of <see cref="LogContext.PushProperty"/> because the latter uses AsyncLocal for properties which will not be available in all contexts.
|
||||
/// </summary>
|
||||
private class NitroxPropEnricher : ILogEventEnricher
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string, object), LogEventProperty> propCache = [];
|
||||
|
||||
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||
{
|
||||
logEvent.AddOrUpdateProperty(propCache.GetOrAdd((nameof(SaveName), SaveName), static (key, factory) => factory.CreateProperty(key.Item1, SaveName), propertyFactory));
|
||||
logEvent.AddOrUpdateProperty(propCache.GetOrAdd((nameof(PlayerName), PlayerName), static (key, factory) => factory.CreateProperty(key.Item1, PlayerName), propertyFactory));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum LogLevel
|
||||
{
|
||||
Verbose = 0,
|
||||
Debug = 1,
|
||||
Information = 2,
|
||||
Warning = 3,
|
||||
Error = 4
|
||||
}
|
||||
}
|
34
NitroxModel/Logger/LoggerSinkConfigurationExtensions.cs
Normal file
34
NitroxModel/Logger/LoggerSinkConfigurationExtensions.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using Serilog;
|
||||
using Serilog.Configuration;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Display;
|
||||
|
||||
namespace NitroxModel.Logger;
|
||||
|
||||
public static class LoggerSinkConfigurationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Buffers messages while the <see cref="Predicate{T}"/> returns true. If false, resumes buffering.
|
||||
/// </summary>
|
||||
public static LoggerConfiguration Valve(
|
||||
this LoggerSinkConfiguration loggerConfiguration,
|
||||
Action<LoggerSinkConfiguration> configure,
|
||||
Func<LogEvent, bool> predicate,
|
||||
LogEventLevel minimumLevel = LogEventLevel.Verbose)
|
||||
{
|
||||
ILogEventSink logEventSink = LoggerSinkConfiguration.Wrap(wrappedSink => new ConditionalValveSink(predicate, wrappedSink), configure);
|
||||
return loggerConfiguration.Sink(logEventSink, minimumLevel);
|
||||
}
|
||||
|
||||
public static LoggerConfiguration Message(
|
||||
this LoggerSinkConfiguration loggerConfiguration,
|
||||
Action<string> writer,
|
||||
LogEventLevel minimumLevel = LogEventLevel.Verbose,
|
||||
string outputTemplate = "{Message}",
|
||||
IFormatProvider formatProvider = null)
|
||||
{
|
||||
return loggerConfiguration.Sink(new MessageSink(new MessageTemplateTextFormatter(outputTemplate, formatProvider), writer), minimumLevel);
|
||||
}
|
||||
}
|
28
NitroxModel/Logger/MessageSink.cs
Normal file
28
NitroxModel/Logger/MessageSink.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting;
|
||||
|
||||
namespace NitroxModel.Logger
|
||||
{
|
||||
public class MessageSink : ILogEventSink
|
||||
{
|
||||
private readonly ITextFormatter formatter;
|
||||
private readonly Action<string> writer;
|
||||
|
||||
public MessageSink(ITextFormatter formatter, Action<string> writer)
|
||||
{
|
||||
this.formatter = formatter;
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
using StringWriter stringWriter = new();
|
||||
formatter.Format(logEvent, stringWriter);
|
||||
stringWriter.Flush();
|
||||
writer(stringWriter.GetStringBuilder().ToString());
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user