feat(logging): rolling file sink under %LOCALAPPDATA%\\TeamsISO\\Logs
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<date>.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.
This commit is contained in:
parent
9891f2444d
commit
1d85396a90
4 changed files with 133 additions and 3 deletions
|
|
@ -75,8 +75,15 @@ public partial class App : Application
|
||||||
|
|
||||||
try
|
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<App>();
|
var logger = _loggerFactory.CreateLogger<App>();
|
||||||
|
logger.LogInformation(
|
||||||
|
"TeamsISO.App starting up. Build: {Version}. Process: {Pid}.",
|
||||||
|
typeof(App).Assembly.GetName().Version,
|
||||||
|
Environment.ProcessId);
|
||||||
|
|
||||||
// ---- Preflight: NDI runtime ----
|
// ---- Preflight: NDI runtime ----
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,35 @@ using Serilog.Extensions.Logging;
|
||||||
namespace TeamsISO.Engine.Logging;
|
namespace TeamsISO.Engine.Logging;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convenience factory for an <see cref="ILoggerFactory"/> wired to Serilog's console sink.
|
/// Convenience factory for an <see cref="ILoggerFactory"/> wired to Serilog. Two flavors:
|
||||||
/// Phase A wires console-only; Phase C will add the rolling-file sink under %APPDATA%\TeamsISO\logs\.
|
/// <see cref="CreateConsole"/> for headless / Console-mode use (writes only to stdout),
|
||||||
|
/// and <see cref="CreateDefault"/> for the WPF host (writes to stdout AND to a rolling
|
||||||
|
/// daily file under <c>%LOCALAPPDATA%\TeamsISO\Logs</c> so support has something to ask for
|
||||||
|
/// when things break).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class EngineLogging
|
public static class EngineLogging
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private const string LogFileName = "teamsiso.log";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default directory for the rolling-file sink:
|
||||||
|
/// <c>%LOCALAPPDATA%\TeamsISO\Logs\</c>. Created on first write if it doesn't exist.
|
||||||
|
/// </summary>
|
||||||
|
public static string DefaultLogDirectory =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"TeamsISO",
|
||||||
|
"Logs");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Console-only factory. Used by TeamsISO.Console where stdout is the right surface
|
||||||
|
/// and a file sink would be redundant with shell redirection.
|
||||||
|
/// </summary>
|
||||||
public static ILoggerFactory CreateConsole(LogLevel minimum = LogLevel.Information)
|
public static ILoggerFactory CreateConsole(LogLevel minimum = LogLevel.Information)
|
||||||
{
|
{
|
||||||
var serilog = new LoggerConfiguration()
|
var serilog = new LoggerConfiguration()
|
||||||
|
|
@ -21,6 +45,50 @@ public static class EngineLogging
|
||||||
return new SerilogLoggerFactory(serilog, dispose: true);
|
return new SerilogLoggerFactory(serilog, dispose: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default factory for the desktop host. Writes to the console (visible when launched
|
||||||
|
/// from a console attach) and to a rolling daily file at <see cref="DefaultLogDirectory"/>.
|
||||||
|
/// File path is logged at startup to the console sink so users can find it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="minimum">Minimum log level for both sinks.</param>
|
||||||
|
/// <param name="logDirectoryOverride">Optional alternate log directory; null uses the default.</param>
|
||||||
|
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
|
private static Serilog.Events.LogEventLevel MapLevel(LogLevel level) => level switch
|
||||||
{
|
{
|
||||||
LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose,
|
LogLevel.Trace => Serilog.Events.LogEventLevel.Verbose,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||||
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
<PackageReference Include="System.Reactive" Version="6.0.0" />
|
<PackageReference Include="System.Reactive" Version="6.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<EngineLoggingTests>().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<T> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue