diff --git a/OliverBooth/Logging/ColorfulConsoleTarget.cs b/OliverBooth/Logging/ColorfulConsoleTarget.cs new file mode 100644 index 0000000..b3185f3 --- /dev/null +++ b/OliverBooth/Logging/ColorfulConsoleTarget.cs @@ -0,0 +1,39 @@ +using System.Text; +using NLog; +using NLog.Targets; +using LogLevel = NLog.LogLevel; + +namespace OliverBooth.Logging; + +/// +/// Represents an NLog target which supports colorful output to stdout. +/// +internal sealed class ColorfulConsoleTarget : TargetWithLayout +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the log target. + public ColorfulConsoleTarget(string name) + { + Name = name; + } + + /// + protected override void Write(LogEventInfo logEvent) + { + var message = new StringBuilder(); + message.Append(Layout.Render(logEvent)); + + if (logEvent.Level == LogLevel.Warn) + Console.ForegroundColor = ConsoleColor.Yellow; + else if (logEvent.Level == LogLevel.Error || logEvent.Level == LogLevel.Fatal) + Console.ForegroundColor = ConsoleColor.Red; + + if (logEvent.Exception is { } exception) + message.Append($": {exception}"); + + Console.WriteLine(message); + Console.ResetColor(); + } +} diff --git a/OliverBooth/Logging/LogFileTarget.cs b/OliverBooth/Logging/LogFileTarget.cs new file mode 100644 index 0000000..bc8373f --- /dev/null +++ b/OliverBooth/Logging/LogFileTarget.cs @@ -0,0 +1,40 @@ +using System.Text; +using NLog; +using NLog.Targets; +using OliverBooth.Services; + +namespace OliverBooth.Logging; + +/// +/// Represents an NLog target which writes its output to a log file on disk. +/// +internal sealed class LogFileTarget : TargetWithLayout +{ + private readonly LoggingService _loggingService; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the log target. + /// The . + public LogFileTarget(string name, LoggingService loggingService) + { + _loggingService = loggingService; + Name = name; + } + + /// + protected override void Write(LogEventInfo logEvent) + { + _loggingService.ArchiveLogFilesAsync(false).GetAwaiter().GetResult(); + + using FileStream stream = _loggingService.LogFile.Open(FileMode.Append, FileAccess.Write); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(Layout.Render(logEvent)); + + if (logEvent.Exception is { } exception) + writer.Write($": {exception}"); + + writer.WriteLine(); + } +} diff --git a/OliverBooth/OliverBooth.csproj b/OliverBooth/OliverBooth.csproj index 13558d6..8a6f2ac 100644 --- a/OliverBooth/OliverBooth.csproj +++ b/OliverBooth/OliverBooth.csproj @@ -15,6 +15,8 @@ + + diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index f244e99..b120cc1 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -1,5 +1,13 @@ +using NLog.Extensions.Logging; +using OliverBooth.Services; +using X10D.Hosting.DependencyInjection; + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.Logging.AddNLog(); +builder.Services.AddHostedSingleton(); + builder.Services.AddRazorPages().AddRazorRuntimeCompilation(); builder.Services.AddControllersWithViews(); builder.Services.AddRouting(options => options.LowercaseUrls = true); diff --git a/OliverBooth/Services/LoggingService.cs b/OliverBooth/Services/LoggingService.cs new file mode 100644 index 0000000..fb57ec4 --- /dev/null +++ b/OliverBooth/Services/LoggingService.cs @@ -0,0 +1,95 @@ +using System.IO.Compression; +using NLog; +using NLog.Config; +using NLog.Layouts; +using OliverBooth.Logging; +using LogLevel = NLog.LogLevel; + +namespace OliverBooth.Services; + +/// +/// Represents a class which implements a logging service that supports multiple log targets. +/// +/// +/// This class implements a logging structure similar to that of Minecraft, where historic logs are compressed to a .gz and +/// the latest log is found in logs/latest.log. +/// +internal sealed class LoggingService : BackgroundService +{ + private const string LogFileName = "logs/latest.log"; + + /// + /// Initializes a new instance of the class. + /// + public LoggingService() + { + LogFile = new FileInfo(LogFileName); + } + + /// + /// Gets or sets the log file. + /// + /// The log file. + public FileInfo LogFile { get; set; } + + /// + /// Archives any existing log files. + /// + public async Task ArchiveLogFilesAsync(bool archiveToday = true) + { + var latestFile = new FileInfo(LogFile.FullName); + if (!latestFile.Exists) return; + + DateTime lastWrite = latestFile.LastWriteTime; + string lastWriteDate = $"{lastWrite:yyyy-MM-dd}"; + var version = 0; + string name; + + if (!archiveToday && lastWrite.Date == DateTime.Today) return; + + while (File.Exists(name = Path.Combine(LogFile.Directory!.FullName, $"{lastWriteDate}-{++version}.log.gz"))) + { + // body ignored + } + + await using (FileStream source = latestFile.OpenRead()) + { + await using FileStream output = File.Create(name); + await using var gzip = new GZipStream(output, CompressionMode.Compress); + await source.CopyToAsync(gzip); + } + + latestFile.Delete(); + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + LogFile.Directory?.Create(); + + LogManager.Setup(builder => builder.SetupExtensions(extensions => + { + extensions.RegisterLayoutRenderer("TheTime", info => info.TimeStamp.ToString("HH:mm:ss")); + extensions.RegisterLayoutRenderer("ServiceName", info => info.LoggerName); + })); + + Layout? layout = Layout.FromString("[${TheTime} ${level:uppercase=true}] [${ServiceName}] ${message}"); + var config = new LoggingConfiguration(); + var fileLogger = new LogFileTarget("FileLogger", this) { Layout = layout }; + var consoleLogger = new ColorfulConsoleTarget("ConsoleLogger") { Layout = layout }; + +#if DEBUG + LogLevel minLevel = LogLevel.Debug; +#else + LogLevel minLevel = LogLevel.Info; + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ENABLE_DEBUG_LOGGING"))) + minLevel = LogLevel.Debug; +#endif + config.AddRule(minLevel, LogLevel.Fatal, consoleLogger); + config.AddRule(minLevel, LogLevel.Fatal, fileLogger); + + LogManager.Configuration = config; + + return ArchiveLogFilesAsync(); + } +}