Imports System.IO Imports System.Reflection Imports NLog Imports NLog.Config Imports NLog.Targets ''' LogConfig ''' 0.0.1.0 ''' 02.10.2018 ''' ''' Module that writes file-logs to different locations: ''' local application data, the current directory or a custom path. ''' Files and directories will be automatically created. ''' ''' ''' NLog, >= 4.5.8 ''' ''' ''' Imports DigitalData.Modules.Logging ''' ''' Class FooProgram ''' Private Logger as Logger ''' Private LogConfig as LogConfig ''' ''' Public Sub New() ''' LogConfig = new LogConfig(args) ''' Logger = LogConfig.GetLogger() ''' End Sub ''' ''' Public Sub Bar() ''' Logger.Info("Baz") ''' End Sub ''' End Class ''' ''' Class FooLib ''' Private Logger as NLog.Logger ''' ''' Public Sub New(LogConfig as LogConfig) ''' Logger = LogConfig.GetLogger() ''' End Sub ''' ''' Public Sub Bar() ''' Logger.Info("Baz") ''' End Sub ''' End Class ''' ''' ''' If logpath can not be written to, falls back to temp folder as defined in: ''' https://docs.microsoft.com/de-de/dotnet/api/system.io.path.gettemppath?view=netframework-4.7.2 ''' ''' If used in a service, LogPath must be set to CustomPath, otherwise the Log will be written to System32! ''' ''' For NLog Troubleshooting, set the following Environment variables to write the NLog internal Log: ''' - NLOG_INTERNAL_LOG_LEVEL: Debug ''' - NLOG_INTERNAL_LOG_FILE: ex. C:\Temp\Nlog_Internal.log ''' Public Class LogConfig #Region "Private Properties" Private Const OPEN_FILE_CACHE_TIMEOUT As Integer = 30 Private Const OPEN_FILE_FLUSH_TIMEOUT As Integer = 5 Private Const AUTO_FLUSH As Boolean = False Private Const KEEP_FILES_OPEN As Boolean = False Private Const KEEP_FILES_OPEN_DEBUG As Boolean = True ' MAX_ARCHIVES_FILES works like this (in version 4.5.8): ' 0 = keep ALL archives files ' 1 = only keep latest logfile and NO archive files ' n = keep n archive files Private Const MAX_ARCHIVE_FILES_DEFAULT As Integer = 0 Private Const MAX_ARCHIVE_FILES_DEBUG_DETAIL As Integer = 0 Private Const ARCHIVE_EVERY As FileArchivePeriod = FileArchivePeriod.Day Private Const FILE_NAME_FORMAT_DEFAULT As String = "${shortdate}-${var:product}${var:suffix}${event-properties:item=ModuleName}.log" Private Const FILE_NAME_FORMAT_DEBUG As String = "${shortdate}-${var:product}${var:suffix}${event-properties:item=ModuleName}-Debug.log" Private Const FILE_NAME_FORMAT_TRACE As String = "${shortdate}-${var:product}${var:suffix}${event-properties:item=ModuleName}-Trace.log" Private Const FILE_NAME_FORMAT_ERROR As String = "${shortdate}-${var:product}${var:suffix}${event-properties:item=ModuleName}-Error.log" Private Const TARGET_DEFAULT As String = "defaultTarget" Private Const TARGET_ERROR_EX As String = "errorExceptionTarget" Private Const TARGET_ERROR As String = "errorTarget" Private Const TARGET_DEBUG As String = "debugTarget" Private Const TARGET_TRACE As String = "traceTarget" 'Private Const TARGET_MEMORY As String = "memoryTarget" Private Const LOG_FORMAT_BASE As String = "${time}|${logger:shortName=True}|${level:uppercase=true}" Private Const LOG_FORMAT_CALLSITE As String = "${callsite:className=false:fileName=true:includeSourcePath=false:methodName=true}" Private Const LOG_FORMAT_EXCEPTION As String = "${exception:format=Message,StackTrace:innerFormat=Message:maxInnerExceptionLevel=3}" Private Const LOG_FORMAT_DEFAULT As String = LOG_FORMAT_BASE & " >> ${message}" Private Const LOG_FORMAT_ERROR As String = LOG_FORMAT_BASE & " >> " & LOG_FORMAT_EXCEPTION Private Const LOG_FORMAT_DEBUG As String = LOG_FORMAT_BASE & " >> " & LOG_FORMAT_CALLSITE & " -> " & "${message}" Private Const FILE_NAME_ACCESS_TEST = "accessTest.txt" Private Const FOLDER_NAME_LOG = "Log" Private Const FILE_KEEP_RANGE As Integer = 30 'Private Const MAX_MEMORY_LOG_COUNT As Integer = 1000 Private ReadOnly _failSafePath As String = Path.GetTempPath() Private ReadOnly _basePath As String = _failSafePath Private _config As LoggingConfiguration Private _isDebug As Boolean = False Private _isTrace As Boolean = False #End Region #Region "Public Properties" Public Enum PathType As Integer AppData = 0 CurrentDirectory = 1 CustomPath = 2 Temp = 3 End Enum ''' ''' Returns the NLog.LogFactory object that is used to create Loggers ''' ''' LogFactory object Public ReadOnly Property LogFactory As LogFactory ''' ''' Returns the path to the current default logfile ''' ''' Filepath to the logfile Public ReadOnly Property LogFile As String ''' ''' Returns the path to the current log directory ''' ''' Directory path to the log directory Public ReadOnly Property LogDirectory As String ''' ''' Determines if a debug log will be written ''' ''' True, if debug log will be written. False otherwise. Public Property Debug As Boolean Get Return _isDebug End Get Set(isDebug As Boolean) _isDebug = isDebug ReloadConfig(isDebug, _isTrace) End Set End Property Public Property Trace As Boolean Get Return _isTrace End Get Set(isTrace As Boolean) _isTrace = isTrace ReloadConfig(_isDebug, isTrace) End Set End Property ''' ''' Returns Logs in Memory as List(Of String) if Debug is enabled ''' Returns an empty list if debug is disabled ''' ''' A list of log messages Public ReadOnly Property Logs As List(Of String) Get 'Dim oTarget = _config.FindTargetByName(Of MemoryTarget)(TARGET_MEMORY) 'Return oTarget?.Logs.ToList() Return New List(Of String) End Get End Property Public ReadOnly Property NLogConfig As LoggingConfiguration Get Return _config End Get End Property #End Region ''' ''' Initializes a new LogConfig object with the options supplied as a LogOptions object ''' ''' Public Sub New(Options As LogOptions) MyClass.New(Options.LogPath, Options.CustomLogPath, Options.Suffix, Options.CompanyName, Options.ProductName, Options.FileKeepInterval) End Sub ''' ''' Initializes a new LogConfig object with a logpath and optinally a filename-suffix. ''' ''' The basepath to write logs to. Can be AppData, CurrentDirectory or CustomPath. ''' If `logPath` is set to custom, this defines the custom logPath. ''' If set to anything other than Nothing, extends the logfile name with this suffix. ''' CompanyName is used to construct log-path in when LogPath is set to PathType:AppData ''' ProductName is used to construct log-path in when LogPath is set to PathType:AppData ''' Amount of days where files are kept and not deleted. Public Sub New(LogPath As PathType, Optional CustomLogPath As String = Nothing, Optional Suffix As String = Nothing, Optional CompanyName As String = Nothing, Optional ProductName As String = Nothing, Optional FileKeepRangeInDays As Integer = FILE_KEEP_RANGE) If LogPath = PathType.AppData And (ProductName Is Nothing Or CompanyName Is Nothing) Then Throw New ArgumentException("Modules.Logging: PathType is AppData and either CompanyName or ProductName was not supplied!") End If If LogPath = PathType.CurrentDirectory Then Throw New ArgumentException("Modules.Logging: LogPath.CurrentDirectory is deprecated. Please use LogPath.CustomPath!") End If If LogPath = PathType.AppData Then Dim appDataDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) _basePath = Path.Combine(appDataDir, CompanyName, ProductName, FOLDER_NAME_LOG) ElseIf LogPath = PathType.Temp Then _basePath = _failSafePath Else 'Custom Path _basePath = CustomLogPath End If ' If directory does not exist, try to create it! If Not Directory.Exists(_basePath) Then Try Directory.CreateDirectory(_basePath) Catch ex As Exception ' If creation fails, use failSafe path _basePath = _failSafePath End Try End If ' Try to create a file in `basePath` to check write permissions Try Dim fileAccessPath = Path.Combine(_basePath, FILE_NAME_ACCESS_TEST) Using fs As FileStream = File.Create(fileAccessPath) fs.WriteByte(0) End Using File.Delete(fileAccessPath) Catch ex As Exception ' If creation fails, use failSafe path _basePath = _failSafePath End Try ' Set the suffix to the given string if it exists Dim logFileSuffix As String = String.Empty If Suffix IsNot Nothing AndAlso Suffix.Count > 0 Then logFileSuffix = $"-{Suffix}" End If Dim oProductName As String = "Main" If ProductName IsNot Nothing Then oProductName = ProductName End If ' Create config object and initalize it _config = GetConfig(oProductName, logFileSuffix) ' Save config LogFactory = New LogFactory With { .Configuration = _config } ' Save log paths for files/directory LogDirectory = _basePath LogFile = GetCurrentLogFilePath() Dim oLogger = GetLogger() oLogger.Info("Logging started for [{0}{1}] in [{2}]", oProductName, logFileSuffix, LogFile) oLogger.Info("Logging Version [{0}]", Assembly.GetExecutingAssembly().GetName().Version) ' Clear old Logfiles as defined in `FileKeepInterval` ClearOldLogfiles(FileKeepRangeInDays) End Sub ''' ''' Clears old LogFiles from the configured logpath for compliance with the GDPR ''' ''' Days in which logfiles should be kept. All files older than `Now - FileKeepInterval` will be deleted. ''' True, if files were deleted as expected or no files were deleted. Otherwise false. Private Function ClearOldLogfiles(FileKeepRange As Integer) As Boolean Dim oClassName As String = GetClassFullName() Dim oLogger As Logger = GetLogger(oClassName) Try Dim oContinueOnError = True Dim oUnableToDeleteCounter = 0 Dim oDirectory As New DirectoryInfo(LogDirectory) Dim oDateLimit As Date = Date.Now.AddDays(-FileKeepRange) Dim oFiles As List(Of FileInfo) = oDirectory. EnumerateFiles(). Where(Function(oFileInfo As FileInfo) oFileInfo.Extension = ".log" And oFileInfo.LastWriteTime < oDateLimit). ToList() If oFiles.Count = 0 Then oLogger.Info("No logfiles were marked for deletion in the range [last {0} days].", FileKeepRange) Return True End If oLogger.Info("Deleting [{0}] old logfiles that are marked for deletion in the range [last {1} days].", oFiles.Count, FileKeepRange) For Each oFile As FileInfo In oFiles Try oFile.Delete() Catch ex As Exception oUnableToDeleteCounter += 1 oLogger.Warn("File {0} could not be deleted!") End Try Next If oUnableToDeleteCounter > 0 Then oLogger.Info("Delete old logfiles partially. {0} files could not be deleted.", oUnableToDeleteCounter) Else oLogger.Info("Deleted [{0}] old logfiles.", oFiles.Count) End If Return True Catch ex As Exception oLogger.Error(ex) Return False End Try End Function ''' ''' Returns the Logger for the calling class ''' ''' An object of Logging.Logger Public Function GetLogger() As Logger Dim oClassName As String = GetClassFullName() Return GetLogger(oClassName, String.Empty) End Function ''' ''' Returns the Logger for the specified classname ''' ''' An object of Logging.Logger Public Function GetLogger(ClassName As String) As Logger Return GetLogger(ClassName, String.Empty) End Function ''' ''' Returns the Logger for the specified module using event-properties ''' ''' ''' https://github.com/NLog/NLog/wiki/EventProperties-Layout-Renderer ''' https://stackoverflow.com/questions/31337030/separate-log-file-for-specific-class-instance-using-nlog/32065824#32065824 ''' ''' An object of Logging.Logger Public Function GetLoggerFor(ModuleName As String) As Logger Dim oClassName As String = GetClassFullName() Return GetLogger(oClassName, ModuleName) End Function ''' ''' Returns the Logger for a class specified by `ClassName` ''' ''' The name of the class the logger belongs to ''' An object of Logging.Logger Public Function GetLogger(ClassName As String, ModuleName As String) As Logger Dim oLogger = LogFactory.GetLogger(Of Logger)(ClassName) If ModuleName IsNot Nothing AndAlso ModuleName.Length > 0 Then Return oLogger.WithProperty("ModuleName", $"-{ModuleName}") End If Return oLogger End Function ''' ''' Clears the internal log ''' Public Sub ClearLogs() 'Dim oTarget = _config.FindTargetByName(Of MemoryTarget)(TARGET_MEMORY) 'oTarget?.Logs.Clear() End Sub ''' ''' Gets the fully qualified name of the class invoking the calling method, ''' including the namespace but Not the assembly. ''' ''' The fully qualified class name ''' This method is very resource-intensive! Public Shared Function GetClassFullName(Optional IncludeMethodNames As Boolean = False, Optional Parts As Integer = 0) As String Dim oFramesToSkip As Integer = 2 Dim oClassName As String = String.Empty Dim oStackTrace = Environment.StackTrace Dim oStackTraceLines = oStackTrace.Replace(vbCr, "").Split({vbLf}, StringSplitOptions.RemoveEmptyEntries) For i As Integer = 0 To oStackTraceLines.Length - 1 Dim oCallingClassAndMethod = oStackTraceLines(i).Split({" ", "<>", "(", ")"}, StringSplitOptions.RemoveEmptyEntries)(1) Dim oMethodStartIndex As Integer = oCallingClassAndMethod.LastIndexOf(".", StringComparison.Ordinal) If oMethodStartIndex > 0 Then If IncludeMethodNames Then oMethodStartIndex = oCallingClassAndMethod.Count End If Dim oCallingClass = oCallingClassAndMethod.Substring(0, oMethodStartIndex) oClassName = oCallingClass.TrimEnd("."c) If Not oClassName.StartsWith("System.Environment") AndAlso oFramesToSkip <> 0 Then i += oFramesToSkip - 1 oFramesToSkip = 0 Continue For End If If Not oClassName.StartsWith("System.") Then Exit For End If Next If Parts > 0 Then Dim oParts = oClassName. Split("."). Reverse(). Take(Parts). Reverse() oClassName = String.Join(".", oParts) End If Return oClassName End Function ''' ''' Returns the initial log configuration ''' ''' The chosen productname ''' The chosen suffix ''' A NLog.LoggingConfiguration object Private Function GetConfig(productName As String, logFileSuffix As String) As LoggingConfiguration _config = New LoggingConfiguration() _config.Variables("product") = productName _config.Variables("suffix") = logFileSuffix ' Add default targets _config.AddTarget(TARGET_ERROR_EX, GetErrorExceptionLogTarget(_basePath)) _config.AddTarget(TARGET_ERROR, GetErrorLogTarget(_basePath)) _config.AddTarget(TARGET_DEFAULT, GetDefaultLogTarget(_basePath)) _config.AddTarget(TARGET_DEBUG, GetDebugLogTarget(_basePath)) _config.AddTarget(TARGET_TRACE, GetTraceLogTarget(_basePath)) '_config.AddTarget(TARGET_MEMORY, GetMemoryDebugTarget()) ' Add default rules AddDefaultRules(_config) Return _config End Function ''' ''' Adds the default rules ''' ''' A NLog.LoggingConfiguration object Private Sub AddDefaultRules(ByRef config As LoggingConfiguration) config.AddRuleForOneLevel(LogLevel.Error, TARGET_ERROR_EX) config.AddRuleForOneLevel(LogLevel.Fatal, TARGET_ERROR_EX) config.AddRuleForOneLevel(LogLevel.Warn, TARGET_DEFAULT) config.AddRuleForOneLevel(LogLevel.Info, TARGET_DEFAULT) 'config.AddRuleForAllLevels(TARGET_MEMORY) End Sub ''' ''' Returns the full path of the current default log file. ''' ''' Full path of the current default log file Private Function GetCurrentLogFilePath() Dim logEventInfo As New LogEventInfo() With {.TimeStamp = Date.Now} Dim target As FileTarget = _config.FindTargetByName(TARGET_DEFAULT) Dim fileName As String = target.FileName.Render(logEventInfo) Return fileName End Function ''' ''' Reconfigures and re-adds all loggers, optionally adding the debug rule. ''' ''' Adds the Debug rule if true. ''' Adds the Trace rule if true. Private Sub ReloadConfig(Optional Debug As Boolean = False, Optional Trace As Boolean = False) Dim oLogger = GetLogger() ' Clear Logging Rules _config.LoggingRules.Clear() ' Add default rules AddDefaultRules(_config) ' Add debug rule, if configured If Debug = True Then _config.AddRule(LogLevel.Debug, LogLevel.Error, TARGET_DEBUG) oLogger.Info("DEBUG Logging is now Enabled.") Else oLogger.Debug("DEBUG Logging is now Disabled.") End If If Trace = True Then _config.AddRule(LogLevel.Trace, LogLevel.Error, TARGET_TRACE) End If ' Reload all running loggers LogFactory.ReconfigExistingLoggers() End Sub #Region "Log Targets" Private Function GetDefaultLogTarget(basePath As String) As FileTarget Dim defaultLog As New FileTarget() With { .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_DEFAULT), .Name = TARGET_DEFAULT, .Layout = LOG_FORMAT_DEFAULT, .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEFAULT, .ArchiveEvery = ARCHIVE_EVERY, .KeepFileOpen = KEEP_FILES_OPEN, .Encoding = Text.Encoding.Unicode } Return defaultLog End Function Private Function GetErrorExceptionLogTarget(basePath As String) As FileTarget Dim errorLogWithExceptions As New FileTarget() With { .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_ERROR), .Name = TARGET_ERROR_EX, .Layout = LOG_FORMAT_ERROR, .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEFAULT, .ArchiveEvery = ARCHIVE_EVERY, .KeepFileOpen = KEEP_FILES_OPEN, .Encoding = Text.Encoding.Unicode } Return errorLogWithExceptions End Function Private Function GetErrorLogTarget(basePath As String) As FileTarget Dim errorLog As New FileTarget() With { .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_ERROR), .Name = TARGET_ERROR, .Layout = LOG_FORMAT_DEFAULT, .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEFAULT, .ArchiveEvery = ARCHIVE_EVERY, .KeepFileOpen = KEEP_FILES_OPEN, .Encoding = Text.Encoding.Unicode } Return errorLog End Function Private Function GetDebugLogTarget(basePath As String) As FileTarget Dim debugLog As New FileTarget() With { .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_DEBUG), .Name = TARGET_DEBUG, .Layout = LOG_FORMAT_DEBUG, .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEBUG_DETAIL, .ArchiveEvery = ARCHIVE_EVERY, .KeepFileOpen = KEEP_FILES_OPEN_DEBUG, .OpenFileCacheTimeout = OPEN_FILE_CACHE_TIMEOUT, .AutoFlush = AUTO_FLUSH, .OpenFileFlushTimeout = OPEN_FILE_FLUSH_TIMEOUT, .Encoding = Text.Encoding.Unicode } Return debugLog End Function Private Function GetTraceLogTarget(basePath As String) As FileTarget Dim debugLog As New FileTarget() With { .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_TRACE), .Name = TARGET_DEBUG, .Layout = LOG_FORMAT_DEBUG, .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEBUG_DETAIL, .ArchiveEvery = ARCHIVE_EVERY, .KeepFileOpen = KEEP_FILES_OPEN_DEBUG, .OpenFileCacheTimeout = OPEN_FILE_CACHE_TIMEOUT, .AutoFlush = AUTO_FLUSH, .OpenFileFlushTimeout = OPEN_FILE_FLUSH_TIMEOUT, .Encoding = Text.Encoding.Unicode } Return debugLog End Function 'Private Function GetMemoryDebugTarget() As MemoryTarget ' Dim memoryLog As New MemoryTarget() With { ' .Layout = LOG_FORMAT_DEBUG, ' .Name = TARGET_MEMORY, ' .OptimizeBufferReuse = True, ' .MaxLogsCount = MAX_MEMORY_LOG_COUNT ' } ' Return memoryLog 'End Function #End Region End Class