public interface IAppLogger
{
///
/// Log a debug/detail message intended for developers or verbose diagnostics.
///
/// The message to log.
void Debug(object? message);
///
/// Log an informational message that represents normal operation or key milestones.
///
/// The message to log.
void Info(object? message);
///
/// Log a warning about an unexpected but recoverable condition.
///
/// The warning message to log.
void Warn(object? message);
///
/// Log an error. Implementations may include exception details when is provided.
///
/// The error message to log.
/// Optional exception associated with the error.
void Error(object? message, Exception? ex = null);
}
public sealed class NullLogger : IAppLogger
{
///
/// Shared instance to avoid unnecessary allocations.
///
public static readonly NullLogger Instance = new NullLogger();
// Private ctor encourages use of Instance when appropriate.
internal NullLogger() { }
public void Debug(object? message) { /* intentionally no-op */ }
public void Info(object? message) { /* intentionally no-op */ }
public void Warn(object? message) { /* intentionally no-op */ }
public void Error(object? message, Exception? ex = null) { /* intentionally no-op */ }
}
}
using log4net;
using log4net.Appender;
using log4net.Layout;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
namespace MyProject.Lib.Logging
{
///
/// Static entry point for library diagnostics.
///
///
///
/// Purpose
/// - Provide a small, test-friendly logging façade for the library so internal components can
/// emit diagnostics without depending on a concrete logging framework.
/// - Expose a single adjustable global logger via and convenient forwarding
/// helpers (, , ,
/// ) so call sites remain concise and testable.
///
///
///
/// Default behavior and test guidance
/// - By default is a which drops messages. Tests
/// and hosts should replace with a deterministic implementation
/// (for example a TestLogger) rather than enabling file-based logging when possible.
/// - Use to restore no-op behavior in teardown. This prevents tests from
/// producing flaky file/IO side-effects.
///
///
///
/// File-based logging via log4net
/// - Use to programmatically enable a rolling file appender:
/// - The method resolves and normalizes the path, ensures the directory exists and performs a
/// lightweight write probe to fail early on permission/path issues.
/// - If the probe succeeds the method registers a UTF-8 rolling appender and sets
/// to a bound to the configured repository.
/// - Hosts may instead call to adopt the host's existing log4net configuration.
/// - Note: programmatic configuration performed by this class mutates the global log4net repository.
/// Only call these helpers during process startup or in controlled integration tests.
///
///
///
/// Non-throwing & best-effort contract
/// - Logging is best-effort. Implementations of must not throw from log calls.
/// - performs defensive checks and will throw on invalid arguments or unwritable
/// paths so hosts can surface configuration errors early. Consumers should catch these exceptions
/// during host initialization; unit tests should avoid using this method.
///
///
///
/// Concurrency and lifetime
/// - The reference is assigned atomically but there is no internal locking for
/// concurrent set/get. Set the global logger early on a single thread during startup to avoid
/// races. For per-component scoping prefer constructor injection of an .
/// - The logging façade and its adapters must be safe for concurrent use; the library may call into
/// the logger from parallel tasks or test runners.
///
///
///
/// Diagnostic tokens and verbosity
/// - The library uses compact, stable tokens in messages to enable reliable assertions in tests.
/// - The file appender uses a human-friendly layout including timestamp, level, logger and message;
/// callers can adjust verbosity via the parameter to .
///
///
///
/// Recommendations for hosts and CI
/// - Prefer wiring a host-level logger and calling or setting
/// to an adapter that forwards to the host. This avoids the library performing global repository changes.
/// - Use file-based logging sparingly in CI; prefer capturing test logs via injected test loggers to keep
/// pipelines hermetic and fast.
///
///
///
/// Example
///
/// // Programmatic file logging (call during startup)
/// Log.SetFile("C:\\logs\\convert.log", "DEBUG");
///
/// // Or use a test logger in unit tests
/// Log.Current = new TestLogger();
///
///
///
public static class Log
{
private static IAppLogger _current = new NullLogger();
///
/// Gets or sets the active logger. If set to null, a no-op logger is used.
///
///
///
/// Consumers (tests, hosts) may set this property to supply a custom logger (for example a
/// test logger). Assigning null will restore a instance to preserve
/// no-op behavior.
///
///
/// Assignments are atomic for the reference; however concurrent read/write races are not
/// synchronized. Set the global logger early during initialization to avoid race conditions.
///
///
public static IAppLogger Current
{
get => _current;
set => _current = value ?? new NullLogger();
}
///
/// Configure file-based logging using a programmatically-created log4net rolling appender.
///
/// Target log file path. The method will create containing directories as needed.
/// Optional log level name (All/Debug/Info/Warn/Error/Fatal/Off). Defaults to "All".
///
///
/// This helper performs the following defensive steps before enabling file logging:
/// - Resolves and normalizes the supplied path.
/// - Ensures the target directory exists.
/// - Probes write access by opening the file for append and immediately closing it.
/// - Creates a rolling file appender with a UTF-8 pattern layout and configures the log4net
/// repository for the current entry assembly.
///
///
/// The method throws an for invalid arguments and an
/// when the path is not writable. Callers should catch and handle
/// these exceptions during host startup; tests should avoid calling this method and instead
/// set directly to a deterministic test logger.
///
///
public static void SetFile(string logFilePath, string logLevel = "All")
{
if (string.IsNullOrWhiteSpace(logFilePath))
throw new ArgumentException("logFilePath cannot be null or empty.", nameof(logFilePath));
var fullPath = ResolvePath(logFilePath);
EnsureDirectoryExists(fullPath);
ProbeWriteAccess(fullPath);
var appender = CreateRollingAppender(fullPath);
var level = ParseLogLevel(logLevel);
ConfigureRepository(appender, level);
Current = new Log4NetAdapter(LogManager.GetLogger(typeof(Log)));
}
///
/// Convert the supplied path into an absolute, normalized form.
///
/// Original path provided by the caller (may be relative).
/// Absolute normalized file path.
///
/// Uses to resolve relative paths against the current
/// working directory. No IO is performed by this helper.
///
private static string ResolvePath(string logFilePath)
{
return Path.GetFullPath(logFilePath);
}
///
/// Ensure the directory for the given file path exists, creating it if necessary.
///
/// Absolute file path whose directory should be present.
///
/// - Extracts the directory portion of and creates it when missing.
/// - Any IO exceptions (for example insufficient permissions) will bubble to the caller.
///
private static void EnsureDirectoryExists(string fullPath)
{
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
}
///
/// Probe whether the process can open the target file for appending.
///
/// Absolute path to the candidate log file.
///
/// This method attempts to open the file with and immediately
/// closes it. If the file cannot be opened for write access (permission denied, path locked,
/// invalid path) the method wraps and throws with a helpful message.
/// Use this probe to fail early during host startup rather than letting log4net fail silently
/// or produce confusing errors later.
///
private static void ProbeWriteAccess(string fullPath)
{
try
{
using (var fs = new FileStream(fullPath, FileMode.Append, FileAccess.Write, FileShare.Read))
{
// just testing write access
}
}
catch (Exception ex)
{
throw new IOException($"Cannot write to log file path '{fullPath}'. Check permissions and that the path is valid.", ex);
}
}
///
/// Create a configured that writes UTF-8 encoded logs.
///
/// Absolute path to the target log file.
/// Initialized suitable for registration with log4net.
///
/// The returned appender:
/// - Uses a human-friendly pattern layout that includes timestamps, level, logger and message.
/// - Rolls files by size with a moderate retention policy (5 backups, 10MB each).
/// - Uses a minimal locking model to reduce contention while allowing other readers to tail the file.
/// - Is activated (options applied) before being returned.
///
private static RollingFileAppender CreateRollingAppender(string fullPath)
{
var layout = new PatternLayout
{
ConversionPattern = "%date %-5level %logger - %message%newline%exception"
};
layout.ActivateOptions();
var roller = new RollingFileAppender
{
AppendToFile = true,
File = fullPath,
Layout = layout,
MaxSizeRollBackups = 5,
MaximumFileSize = "10MB",
RollingStyle = RollingFileAppender.RollingMode.Size,
StaticLogFileName = true,
LockingModel = new FileAppender.MinimalLock(),
Encoding = System.Text.Encoding.UTF8
};
roller.ActivateOptions();
return roller;
}
///
/// Parse a textual log level token into a log4net .
///
/// Log level string provided by the user (case-insensitive).
/// Corresponding log4net level; falls back to All when unknown.
///
/// - Recognized tokens: ALL, DEBUG, INFO, WARN, ERROR, FATAL, OFF (case-insensitive).
/// - The method resolves the log4net repository for the current entry assembly and reads the level
/// from the repository's level map. This allows the returned value to be consistent with the
/// repository's configured levels.
/// - When the repository or level cannot be resolved the method falls back to Level.All.
///
private static log4net.Core.Level ParseLogLevel(string logLevel)
{
var levelMap = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["ALL"] = "ALL",
["DEBUG"] = "DEBUG",
["INFO"] = "INFO",
["WARN"] = "WARN",
["ERROR"] = "ERROR",
["FATAL"] = "FATAL",
["OFF"] = "OFF"
};
var normalizedLevel = (logLevel ?? "ALL").Trim().ToUpperInvariant();
var levelName = levelMap.ContainsKey(normalizedLevel) ? levelMap[normalizedLevel] : "ALL";
var entry = Assembly.GetEntryAssembly();
var repoAssembly = (Assembly)(entry ?? Assembly.GetExecutingAssembly());
var repo = LogManager.GetRepository(repoAssembly);
var hierarchy = repo as log4net.Repository.Hierarchy.Hierarchy;
var level = hierarchy?.LevelMap[levelName] ?? hierarchy?.LevelMap["ALL"] ?? log4net.Core.Level.All;
return level;
}
///
/// Configure the log4net repository with the provided appender and root level.
///
/// Appender to register with the repository.
/// Root logging level to apply to the repository.
///
///
/// This method:
/// - Resolves the log4net repository for the entry assembly (or current assembly fallback).
/// - Resets existing configuration to ensure deterministic behaviour for programmatic setup.
/// - Registers the provided appender using .
/// - Sets the root logger level and marks the hierarchy as configured.
///
///
/// Note: this routine intentionally performs global repository changes. Callers should only
/// invoke it during host initialization or in dedicated integration tests. It is not suitable
/// for ad-hoc changes inside library helpers that expect to be reentrant.
///
///
private static void ConfigureRepository(RollingFileAppender appender, log4net.Core.Level level)
{
var entry = Assembly.GetEntryAssembly();
var repoAssembly = (Assembly)(entry ?? Assembly.GetExecutingAssembly());
var repo = LogManager.GetRepository(repoAssembly);
var hierarchy = repo as log4net.Repository.Hierarchy.Hierarchy;
if (hierarchy != null)
{
hierarchy.ResetConfiguration();
}
log4net.Config.BasicConfigurator.Configure(repo, appender);
if (hierarchy != null)
{
hierarchy.Root.Level = level;
hierarchy.Configured = true;
}
}
///
/// Enable logging using the existing log4net configuration.
///
///
///
/// Sets to a that forwards all calls to the
/// configured log4net repository. This is useful when the host configures log4net externally (for
/// example via XML configuration or host wiring) and the library should piggy-back on the host's
/// logging setup.
///
///
/// For unit tests prefer setting directly to a test logger to keep tests
/// deterministic and avoid file/IO side-effects.
///
///
public static void Enable()
{
var logger = LogManager.GetLogger(typeof(Log));
Current = new Log4NetAdapter(logger);
}
///
/// Disable logging and revert to a no-op logger.
///
///
///
/// Sets to so subsequent library log calls are no-ops.
/// This is useful in tests to suppress noisy logs or to temporarily disable file logging.
///
///
/// This method does not modify the log4net repository configuration; appenders remain registered. To fully
/// reset log4net one would need to interact with the repository API directly (not performed here).
///
///
public static void Disable()
{
Current = new NullLogger();
}
///
/// Log a debug message via the active logger.
///
///
/// Forwarding method to avoid callers referencing directly. Implementations of
/// should be non-throwing and lightweight.
///
public static void Debug(string message) => _current.Debug(message);
///
/// Log an informational message via the active logger.
///
///
/// Intended for high-level lifecycle and milestone messages.
///
public static void Info(string message) => _current.Info(message);
///
/// Log a warning via the active logger.
///
///
/// Warnings indicate recoverable/unexpected conditions that merit attention.
///
public static void Warn(string message) => _current.Warn(message);
///
/// Log an error via the active logger, optionally with an exception.
///
///
/// Error logging should be used for failures that affect operation. Avoid passing large exception
/// objects across process boundaries; prefer serializing minimal diagnostic info when logs are
/// collected by external systems.
///
public static void Error(string message, Exception? ex = null) => _current.Error(message, ex);
}
}