From 1d85396a90511be41e1762d6289cf09e70f425c1 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 8 May 2026 00:47:25 -0400 Subject: [PATCH] feat(logging): rolling file sink under %LOCALAPPDATA%\\TeamsISO\\Logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Serilog.Sinks.File to TeamsISO.Engine and a new EngineLogging.CreateDefault() factory that writes to BOTH the existing console sink and a rolling daily file at %LOCALAPPDATA%\\TeamsISO\\Logs\\teamsiso.log. The WPF host (TeamsISO.exe is a WinExe with no console attached at runtime) now uses CreateDefault so support has something to ask for when users file an issue. The Console build keeps using CreateConsole — stdout is the right surface there and shell redirection beats a competing on-disk sink. Files roll daily, cap at 10 MB before mid-day rollover, and only the most recent 14 are retained. Disk flush interval is 250 ms so a tail -f from another tool sees lines promptly. Path is announced via the first log line on every startup. Two unit tests gate the wiring: AllLoggers_WriteToFile (verifies both typed and named CreateLogger() reach the file) and LogsAtBelowMinimumLevel_AreSuppressed (regression guard for level filtering). 74/74 unit tests pass (was 72). Also adds a startup breadcrumb log line in App.OnStartup carrying the build version + PID so we can correlate a user's log file with a specific commit. --- src/TeamsISO.App/App.xaml.cs | 9 ++- src/TeamsISO.Engine/Logging/EngineLogging.cs | 72 ++++++++++++++++++- src/TeamsISO.Engine/TeamsISO.Engine.csproj | 1 + .../Logging/EngineLoggingTests.cs | 54 ++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/tests/TeamsISO.Engine.Tests/Logging/EngineLoggingTests.cs diff --git a/src/TeamsISO.App/App.xaml.cs b/src/TeamsISO.App/App.xaml.cs index c384870..7937705 100644 --- a/src/TeamsISO.App/App.xaml.cs +++ b/src/TeamsISO.App/App.xaml.cs @@ -75,8 +75,15 @@ public partial class App : Application try { - _loggerFactory = EngineLogging.CreateConsole(LogLevel.Information); + // WPF host: write to both console (visible if attached) and a rolling daily + // file under %LOCALAPPDATA%\TeamsISO\Logs so users have something to grab when + // they file an issue. + _loggerFactory = EngineLogging.CreateDefault(LogLevel.Information); var logger = _loggerFactory.CreateLogger(); + logger.LogInformation( + "TeamsISO.App starting up. Build: {Version}. Process: {Pid}.", + typeof(App).Assembly.GetName().Version, + Environment.ProcessId); // ---- Preflight: NDI runtime ---- try diff --git a/src/TeamsISO.Engine/Logging/EngineLogging.cs b/src/TeamsISO.Engine/Logging/EngineLogging.cs index accf2ff..ec02e69 100644 --- a/src/TeamsISO.Engine/Logging/EngineLogging.cs +++ b/src/TeamsISO.Engine/Logging/EngineLogging.cs @@ -5,11 +5,35 @@ using Serilog.Extensions.Logging; namespace TeamsISO.Engine.Logging; /// -/// Convenience factory for an wired to Serilog's console sink. -/// Phase A wires console-only; Phase C will add the rolling-file sink under %APPDATA%\TeamsISO\logs\. +/// Convenience factory for an wired to Serilog. Two flavors: +/// for headless / Console-mode use (writes only to stdout), +/// and for the WPF host (writes to stdout AND to a rolling +/// daily file under %LOCALAPPDATA%\TeamsISO\Logs so support has something to ask for +/// when things break). /// public static class EngineLogging { + /// + /// Default filename for the rolling-file sink. Serilog rotates on size and date with + /// the suffix it inserts before the extension, so "teamsiso.log" becomes + /// "teamsiso20260508.log", "teamsiso20260508_001.log", etc. + /// + private const string LogFileName = "teamsiso.log"; + + /// + /// Default directory for the rolling-file sink: + /// %LOCALAPPDATA%\TeamsISO\Logs\. Created on first write if it doesn't exist. + /// + public static string DefaultLogDirectory => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TeamsISO", + "Logs"); + + /// + /// Console-only factory. Used by TeamsISO.Console where stdout is the right surface + /// and a file sink would be redundant with shell redirection. + /// public static ILoggerFactory CreateConsole(LogLevel minimum = LogLevel.Information) { var serilog = new LoggerConfiguration() @@ -21,6 +45,50 @@ public static class EngineLogging return new SerilogLoggerFactory(serilog, dispose: true); } + /// + /// Default factory for the desktop host. Writes to the console (visible when launched + /// from a console attach) and to a rolling daily file at . + /// File path is logged at startup to the console sink so users can find it. + /// + /// Minimum log level for both sinks. + /// Optional alternate log directory; null uses the default. + public static ILoggerFactory CreateDefault( + LogLevel minimum = LogLevel.Information, + string? logDirectoryOverride = null) + { + var dir = logDirectoryOverride ?? DefaultLogDirectory; + Directory.CreateDirectory(dir); // idempotent; safe under contention + var logPath = Path.Combine(dir, LogFileName); + + var serilog = new LoggerConfiguration() + .MinimumLevel.Is(MapLevel(minimum)) + .Enrich.WithProperty("Component", "TeamsISO.Engine") + .WriteTo.Console(outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] [{Component}] {Message:lj}{NewLine}{Exception}") + .WriteTo.File( + path: logPath, + outputTemplate: + "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] [{Component}] {Message:lj}{NewLine}{Exception}", + rollingInterval: Serilog.RollingInterval.Day, + retainedFileCountLimit: 14, // two weeks of history is plenty + fileSizeLimitBytes: 10 * 1024 * 1024, // 10 MB per file before roll-over + rollOnFileSizeLimit: true, + shared: false, + flushToDiskInterval: TimeSpan.FromMilliseconds(250)) // flush often so support tools see live tails + .CreateLogger(); + + // Set the Serilog static singleton too. SerilogLoggerFactory writes through the + // explicit logger we pass in, but anything in the engine that reaches for + // Serilog.Log.* directly would otherwise miss our sinks. Belt & suspenders. + Serilog.Log.Logger = serilog; + + var factory = new SerilogLoggerFactory(serilog, dispose: true); + // Surface the path at startup so support has it without digging. + factory.CreateLogger("TeamsISO.Engine") + .LogInformation("Diagnostic logs writing to: {LogDirectory}", dir); + return factory; + } + private static Serilog.Events.LogEventLevel MapLevel(LogLevel level) => level switch { LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose, diff --git a/src/TeamsISO.Engine/TeamsISO.Engine.csproj b/src/TeamsISO.Engine/TeamsISO.Engine.csproj index 310a2dd..605a92a 100644 --- a/src/TeamsISO.Engine/TeamsISO.Engine.csproj +++ b/src/TeamsISO.Engine/TeamsISO.Engine.csproj @@ -9,6 +9,7 @@ + diff --git a/src/tests/TeamsISO.Engine.Tests/Logging/EngineLoggingTests.cs b/src/tests/TeamsISO.Engine.Tests/Logging/EngineLoggingTests.cs new file mode 100644 index 0000000..5c949bc --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Logging/EngineLoggingTests.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using TeamsISO.Engine.Logging; + +namespace TeamsISO.Engine.Tests.Logging; + +public class EngineLoggingTests : IDisposable +{ + private readonly string _dir; + + public EngineLoggingTests() + { + _dir = Path.Combine(Path.GetTempPath(), $"teamsiso-log-{Guid.NewGuid():N}"); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort */ } + } + + [Fact] + public void CreateDefault_AllLoggers_WriteToFile() + { + // Multiple ILoggers from the same factory must all land in the file sink — + // catches regressions in CreateDefault wiring (e.g. if SerilogLoggerFactory + // swaps to Log.Logger silently and our static singleton isn't set). + var factory = EngineLogging.CreateDefault(LogLevel.Information, logDirectoryOverride: _dir); + factory.CreateLogger().LogInformation("typed-logger-line"); + factory.CreateLogger("Custom.Category").LogInformation("named-logger-line"); + factory.Dispose(); // disposes the wrapped Serilog logger -> flush + close file + + var logFiles = Directory.GetFiles(_dir, "*.log"); + logFiles.Should().HaveCount(1); + + var content = File.ReadAllText(logFiles[0]); + content.Should().Contain("typed-logger-line", + because: "logs from a typed CreateLogger must reach the file sink"); + content.Should().Contain("named-logger-line", + because: "logs from a named CreateLogger(string) must reach the file sink"); + } + + [Fact] + public void CreateDefault_LogsAtBelowMinimumLevel_AreSuppressed() + { + var factory = EngineLogging.CreateDefault(LogLevel.Warning, logDirectoryOverride: _dir); + factory.CreateLogger("X").LogInformation("info-should-be-suppressed"); + factory.CreateLogger("X").LogWarning("warn-should-appear"); + factory.Dispose(); + + var content = File.ReadAllText(Directory.GetFiles(_dir, "*.log").Single()); + content.Should().NotContain("info-should-be-suppressed"); + content.Should().Contain("warn-should-appear"); + } +}