From 3985e57383dc9de6e0554543d98d32a5a8eccd42 Mon Sep 17 00:00:00 2001 From: hazre <37149950+hazre@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:29:02 +0100 Subject: [PATCH] feat(Logging): Add log archiving --- BepInEx.Core/Bootstrap/BaseChainloader.cs | 14 +++ BepInEx.Core/Logging/DiskLogListener.cs | 4 +- BepInEx.Core/Logging/LogArchiver.cs | 129 ++++++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 BepInEx.Core/Logging/LogArchiver.cs diff --git a/BepInEx.Core/Bootstrap/BaseChainloader.cs b/BepInEx.Core/Bootstrap/BaseChainloader.cs index a100d75c6..908af2d81 100644 --- a/BepInEx.Core/Bootstrap/BaseChainloader.cs +++ b/BepInEx.Core/Bootstrap/BaseChainloader.cs @@ -229,9 +229,14 @@ protected virtual void InitializeLoggers() } if (ConfigDiskLogging.Value) + { + if (!ConfigDiskAppend.Value && Paths.BepInExRootPath != null) + LogArchiver.ArchiveExistingLogs(Paths.BepInExRootPath, ConfigLogHistoryRetentionDays.Value); + Logger.Listeners.Add(new DiskLogListener("LogOutput.log", ConfigDiskLoggingDisplayedLevel.Value, ConfigDiskAppend.Value, ConfigDiskLoggingInstantFlushing.Value, ConfigDiskLoggingFileLimit.Value)); + } if (!TraceLogSource.IsListening) Logger.Sources.Add(TraceLogSource.CreateSource()); @@ -549,5 +554,14 @@ private static void TryRunModuleCtor(PluginInfo plugin, Assembly assembly) .AppendLine("As one log file is used per open game instance, you may find it necessary to increase this limit when debugging multiple instances at the same time.") .ToString()); + private static readonly ConfigEntry ConfigLogHistoryRetentionDays = ConfigFile.CoreConfig.Bind( + "Logging.Disk", "LogHistoryRetentionDays", + 14, + new StringBuilder() + .AppendLine("Number of days to keep archived log files in the BepInEx/Logs directory.") + .AppendLine("Logs older than this will be automatically deleted on startup.") + .AppendLine("Set to 0 to disable log archiving.") + .ToString()); + #endregion } diff --git a/BepInEx.Core/Logging/DiskLogListener.cs b/BepInEx.Core/Logging/DiskLogListener.cs index 5e2bb4695..689da572e 100644 --- a/BepInEx.Core/Logging/DiskLogListener.cs +++ b/BepInEx.Core/Logging/DiskLogListener.cs @@ -32,10 +32,11 @@ public DiskLogListener(string localPath, DisplayedLogLevel = displayedLogLevel; var counter = 1; + var fullLogPath = Path.Combine(Paths.BepInExRootPath, localPath); FileStream fileStream; - while (!Utility.TryOpenFileStream(Path.Combine(Paths.BepInExRootPath, localPath), + while (!Utility.TryOpenFileStream(fullLogPath, appendLog ? FileMode.Append : FileMode.Create, out fileStream, share: FileShare.Read, access: FileAccess.Write)) { @@ -49,6 +50,7 @@ public DiskLogListener(string localPath, Logger.Log(LogLevel.Warning, $"Couldn't open log file '{localPath}' for writing, trying another..."); localPath = $"LogOutput.{counter++}.log"; + fullLogPath = Path.Combine(Paths.BepInExRootPath, localPath); } LogWriter = TextWriter.Synchronized(new StreamWriter(fileStream, Utility.UTF8NoBom)); diff --git a/BepInEx.Core/Logging/LogArchiver.cs b/BepInEx.Core/Logging/LogArchiver.cs new file mode 100644 index 000000000..a5ed14c2e --- /dev/null +++ b/BepInEx.Core/Logging/LogArchiver.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; + +namespace BepInEx.Logging; + +/// +/// Handles archiving and cleanup of log files. +/// +public static class LogArchiver +{ + private const string LogArchiveDirectory = "Logs"; + + /// + /// Archives all existing log files before starting a new logging session. + /// + /// Root path where logs are stored. + /// Number of days to retain archived logs. 0 or negative disables archiving. + public static void ArchiveExistingLogs(string logsRootPath, int retentionDays) + { + if (retentionDays <= 0) + return; + + try + { + var archiveDirectory = Path.Combine(logsRootPath, LogArchiveDirectory); + Directory.CreateDirectory(archiveDirectory); + + var primaryLogPath = Path.Combine(logsRootPath, "LogOutput.log"); + TryArchiveLogFile(primaryLogPath, archiveDirectory); + + foreach (var fallbackLog in Directory.GetFiles(logsRootPath, "LogOutput.*.log")) + { + if (fallbackLog.EndsWith("-prev.log")) + continue; + + TryArchiveLogFile(fallbackLog, archiveDirectory); + } + + CleanupOldLogs(archiveDirectory, retentionDays); + } + catch (Exception ex) + { + Logger.Log(LogLevel.Warning, $"Failed to archive logs: {ex.Message}"); + } + } + + /// + /// Archives a single log file with a timestamped filename. + /// + /// Full path to the source log file. + /// Directory where archived logs are stored. + /// True if archiving succeeded, false otherwise. + public static bool TryArchiveLogFile(string sourceLogPath, string archiveDirectory) + { + try + { + if (!File.Exists(sourceLogPath)) + return false; + + var fileInfo = new FileInfo(sourceLogPath); + if (fileInfo.Length == 0) + return false; + + var timestamp = fileInfo.LastWriteTime.ToString("yyyy-MM-dd_HH-mm-ss"); + var sourceFileName = Path.GetFileNameWithoutExtension(sourceLogPath); + + var archiveFileName = $"{sourceFileName}_{timestamp}.log"; + var archivePath = Path.Combine(archiveDirectory, archiveFileName); + + var counter = 1; + while (File.Exists(archivePath) && counter < 100) + { + archiveFileName = $"{sourceFileName}_{timestamp}_{counter++}.log"; + archivePath = Path.Combine(archiveDirectory, archiveFileName); + } + + if (counter >= 100) + { + Logger.Log(LogLevel.Warning, $"Too many log files with same timestamp, skipping archive of '{sourceLogPath}'"); + return false; + } + + File.Move(sourceLogPath, archivePath); + return true; + } + catch (Exception ex) + { + Logger.Log(LogLevel.Warning, $"Failed to archive log file '{sourceLogPath}': {ex.Message}"); + return false; + } + } + + /// + /// Deletes archived log files older than the specified retention period. + /// + /// Directory containing archived logs. + /// Number of days to retain logs. Files older than this are deleted. + public static void CleanupOldLogs(string archiveDirectory, int retentionDays) + { + if (retentionDays <= 0) + return; + + try + { + if (!Directory.Exists(archiveDirectory)) + return; + + var cutoffDate = DateTime.Now.AddDays(-retentionDays); + + foreach (var logFile in Directory.GetFiles(archiveDirectory, "*.log")) + { + try + { + var fileInfo = new FileInfo(logFile); + if (fileInfo.LastWriteTime < cutoffDate) + File.Delete(logFile); + } + catch (Exception ex) + { + Logger.Log(LogLevel.Warning, $"Failed to delete old log file '{logFile}': {ex.Message}"); + } + } + } + catch (Exception ex) + { + Logger.Log(LogLevel.Warning, $"Failed to cleanup old logs: {ex.Message}"); + } + } +}