Files
DP44/Common/DTS.Common.Utilities/TextLogger.cs
2026-04-17 14:55:32 -04:00

575 lines
23 KiB
C#

/*
* TextLogger.cs
*
* Copyright © 2010
* Diversified Technical Systems, Inc.
* All Rights Reserved.
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
// ReSharper disable EmptyGeneralCatchClause
namespace DTS.Common.Utilities.Logging
{
// DEVELOPMENT NOTES:
//
// -> Keeping the regular logging functionality in place, we will have another logger that simply
// writes the same thing to a temporary file. At the point we commit to a test ID, the temp
// file will be moved to the associated test directory and logging will resume at that location.
// Once the test is finished, another not-yet-test-related temp file will be created and the
// process will continue from there.
//
// -> Due to existing limitations of the current Logging.TextLogger class it will
// be necessary to fundamentally revamp so that it will be able to synchronize log message input
// with logger control state changes so that, for example, we don't lose log events that happen
// while we're changing the location of the "temp" log file, which requires time to close the old
// file, move it, and then open the new write handle. The simplest solution to me seems to be a
// queue for the write queue, so that when the "test ID is known" event fires is invoked in the
// logger it goes something like this:
//
// 1) Cut-off processing of the the "pre-write queue" queue so that any subsequent log messages
// are held for the moment.
//
// 2) Wait until the "write" queue finishes writing to temp log file.
//
// 3) Close write handle.
//
// 4) Move temp log file to test-specific location.
//
// 5) Open write handle.
//
// 6) Let "pre-write queue" start feeding "write" queue again.
//
// -> Do we need to add parameters for the pre/write queues? Could make that part of the
// mechanism to disconnect them from the feeders and let them evaporate when it's time to
// switch filenames. Could we do that? Just alter the behavior to be evaporate on shutdown,
// activate it, and then create a new instance of the cycle with the new filename.
//
// -> Everytime the filename is set, the object should just allocate a new write cycle,
// after flaging the previous one for termination.
//
// -> Ok, we're going to associate a "handle" structure with the write cycle that will
// contain the queue, control booleans, trigger events, etc. That way we really can just
// set the "termination" bool to true, trigger a cycle and then just let go of the balloon
// when we're ready to start another write cycle associated with another log pathname. The
// expired queue closure will finish writing, then close the writer and wait for disposal.
// This will also simplify things as we won't need to hammer the bugs out of this rather
// baroque pausable two-queue system.
//
// -> Furthermore, the new write cycle closure will be created and launched (and the old
// one set for disposal) by the set accessor of the TextWriter's LogPathname property.
// That way you really don't have to think about anything; just change the name and keep
// logging and all should be well.
//
/// <inheritdoc />
/// <summary>
/// A class to implement a threaded log file.
/// Problems encountered by the writing thread will be reported thru the callback function.
/// The logger is re/started whenever it is assigned a new log filepath.
/// </summary>
public partial class TextLogger : IDisposable
{
/// <summary>
/// The <see cref="WriteCycleHandle"/> associated with the current
/// active log file.
/// </summary>
protected WriteCycleHandle CurrentLoggingCycle
{
get;
set;
}
/// <summary>
/// Shut down the current write cycle.
/// </summary>
protected void EndCurrentWriteCycle()
{
try
{
if (null == CurrentLoggingCycle) return;
CurrentLoggingCycle.TerminateWriteCycle = true;
CurrentLoggingCycle.CycleTrigger.Set();
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem ending current writing cycle", ex);
}
}
/// <summary>
/// Open a log file with the specified name and start processing log messages into it. If another
/// log file is currently active, it will be closed, flushed and disposed.
/// </summary>
///
/// <param name="logPathname">
/// The <see cref="string"/> pathname of the logfile to receive new log messages.
/// </param>
///
protected void StartNewWriteCycle(string logPathname)
{
try
{
if (string.IsNullOrEmpty(logPathname))
throw new NullReferenceException("log pathname cannot be empty/null");
if (null != CurrentLoggingCycle)
{
lock (CurrentLoggingCycle.WriteQueue)
{
EndCurrentWriteCycle();
}
QueueWriteCycle(CurrentLoggingCycle = new WriteCycleHandle(logPathname, new Queue<string>(), null, OnWriteException, false));
}
else
QueueWriteCycle(CurrentLoggingCycle = new WriteCycleHandle(logPathname, new Queue<string>(), null, OnWriteException, false));
}
catch (Exception ex)
{
throw new ApplicationException("encounterd problem starting new logging cycle", ex);
}
}
/// <summary>
/// Change the name/location of the currently active log file without dropping any
/// incoming messages.
/// </summary>
///
/// <param name="newPathName">
/// The new path name <see cref="string"/> for the active log file.
/// </param>
///
public void MoveLogTo(string newPathName)
{
try
{
APILogger.Log("Moving file");
//
// Here's the plan: have a common lock around the old and new queues that result when you
// add a new log file. When it comes time to do a "move", take the lock, mark the old queue handle
// for termination, and on the termination callback return create the new file. Can it be that simple?
//
lock (this)
{
CurrentLoggingCycle.OnWriteCycleTermination = delegate
{
try
{
if (!Path.GetFullPath(LogPathname).Equals(Path.GetFullPath(newPathName)) &&
File.Exists(LogPathname))
{
if (File.Exists(newPathName))
File.Delete(newPathName);
File.Move(LogPathname, newPathName);
}
}
catch (Exception ex)
{
OnWriteException?.Invoke(new ApplicationException("encountered problem moving log file to path " + (null != newPathName ? "\"" + newPathName + "\"" : "<NULL>"), ex));
}
finally
{
LogPathname = newPathName;
}
};
CurrentLoggingCycle.TerminateWriteCycle = true;
}
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem moving log to new path " + (null != newPathName ? "\"" + newPathName + "\"" : "<NULL>"), ex);
}
}
private static readonly object LogPathNameLock = new object();
/// <summary>
/// Get/set the <see cref="string"/> pathname of the currently active log file.
/// </summary>
public string LogPathname
{
get
{
if (null != _logPathname) { return _logPathname; }
throw new LogPathnameNotInitializedException("the pathname has not been initialized");
}
set
{
try
{
lock (LogPathNameLock)
{
// Do not use System.IO.Path.GetFullPath here to compare the new value to the old
// because on XP SaveFileDialog temporarily changes the current directory
// and GetFullPath uses GetCurrentDirectory. So, with an ECM connected, that will
// cause errors from the text logger when CSV or ISO exports wait in SaveFileDialog.
if (null == _logPathname || value != _logPathname)
{
// If we're actually changing the name/location of this thing, then let's stop writing the
// old file and start in on the new one.
//
_logPathname = value;
if (!ThreadPool.QueueUserWorkItem(delegate { StartNewWriteCycle(LogPathname); }))
throw new ApplicationException(Properties.Resources.TextLogger_TextLogger_EnqueueWriterFailureString);
}
}
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem setting log pathname", ex);
}
}
}
private string _logPathname = null;
public static void CompressLogFilesIntoZip(string sourcedir, string destFile)
{
var z = new ICSharpCode.SharpZipLib.Zip.FastZip();
z.CreateZip(destFile, sourcedir, false, null);
}
/// <summary>
/// Get the filename portion of the associated log's filename.
/// </summary>
public string LogFilename
{
get
{
try
{
// Consider the filename to be anything in the full log file name beyond the last path
// separator character.
//
return Path.GetFileName(LogPathname);
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem getting log file name", ex);
}
}
}
/// <summary>
/// A callback type for write queue events.
/// </summary>
///
/// <param name="writeQueue">
/// The write queue's <see cref="string"/> queue.
/// </param>
///
public delegate void WriteCycleCallback(Queue<string> writeQueue);
/// <summary>
/// A callback type to be invoked when exceptions occur in the text writer's threaded
/// writing method.
/// </summary>
/// <param name="ex"></param>
public delegate void WriteCycleExceptionHandler(Exception ex);
/// <summary>
/// The <see cref="Logging.TextLogger.WriteCycleExceptionHandler"/> that well be invoked
/// whenever the logger encounters an exceptional condition within the write cycle method.
/// </summary>
public WriteCycleExceptionHandler OnWriteException { get; set; }
/// <summary>
/// text written to the start of a log (such as application version, etc)
/// if null, no text is written
/// </summary>
public string LogStartMessage { get; set; } = null;
/// <summary>
/// The write loop that processes queued log messages. It is run in its own thread.
/// </summary>
///
/// <param name="writeCycle">
/// The <see cref="TextLogger.WriteCycleHandle"/> associated with the
/// log file to receive processed log messages.
/// </param>
///
private void QueueWriteCycle(WriteCycleHandle writeCycle)
{
try
{
var fullPath = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location),
writeCycle.FullLogFileName);
while (!writeCycle.TerminateWriteCycle)
{
writeCycle.CycleTrigger.WaitOne();
// Do not use System.IO.File.Exists without explicitly specifying the full path,
// based on System.Reflection.Assembly.GetExecutingAssembly().Location,
// because on XP SaveFileDialog temporarily changes the current directory
// and File.Exists uses GetCurrentDirectory. So, with an ECM connected, that will
// cause errors from the text logger when CSV or ISO exports wait in SaveFileDialog.
var bFileExists = File.Exists(fullPath);
if (!bFileExists)
{
using (var fs = new FileStream(writeCycle.FullLogFileName, FileMode.Create))
{
fs.Close();
}
try
{
File.SetCreationTime(writeCycle.FullLogFileName, DateTime.Now);
}
catch (Exception)
{
//ignore
}
}
using (var fs = new FileStream(writeCycle.FullLogFileName, FileMode.Append))
{
using (var gs = new StreamWriter(fs))
{
lock (writeCycle.WriteQueue)
{
if (!bFileExists && null != LogStartMessage)
{
gs.Write(LogStartMessage);
}
while (writeCycle.WriteQueue.Count > 0)
{
try
{
_loggingMutex.WaitOne();
gs.Write(writeCycle.WriteQueue.Dequeue());
}
catch
{
//ignore
}
finally { _loggingMutex.ReleaseMutex(); }
}
}
}
}
try
{
_loggingMutex.WaitOne();
CheckForLargeLogFileAndRename(writeCycle.FullLogFileName);
}
catch (Exception)
{
//ignore
}
finally
{
_loggingMutex.ReleaseMutex();
}
}
writeCycle.CycleCompletionTrigger.Set();
writeCycle.OnWriteCycleTermination?.Invoke(writeCycle.WriteQueue);
}
catch (Exception ex)
{
if (null != writeCycle.OnWriteCycleException)
{
try
{
File.AppendAllText("LOGERROR.LOG", ex.StackTrace + Environment.NewLine + ex.Message);
}
catch (Exception)
{
//ignore
}
writeCycle.OnWriteCycleException(new ApplicationException("encountered problem processing text logger queue", ex));
}
if (!writeCycle.TerminateCycleOnException)
QueueWriteCycle(writeCycle);
}
}
/// <summary>
/// Initialize an instance of the <see cref="Logging.TextLogger"/> class.
/// </summary>
///
/// <param name="logPathname">
/// The <see cref="string"/> pathname of the log file that will be generated by
/// this class.
/// </param>
///
/// <param name="onWriteException">
/// The <see cref="TextLogger.WriteCycleExceptionHandler"/> to be invoked when
/// an exception occurs in the queue processing loop.
/// </param>
///
public TextLogger(string logPathname, WriteCycleExceptionHandler onWriteException)
{
try
{
OnWriteException = onWriteException;
LogPathname = logPathname;
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem constructing " + typeof(TextLogger).FullName + " object", ex);
}
}
/// <summary>
/// Initialize an instance of the <see cref="TextLogger"/> class.
/// </summary>
///
/// <param name="logPathname">
/// The <see cref="string"/> pathname of the log file that will be generated by
/// this class.
/// </param>
///
/// <param name="onWriteException">
/// The <see cref="TextLogger.WriteCycleExceptionHandler"/> to be invoked when
/// an exception occurs in the queue processing loop.
/// </param>
/// <param name="maxLogFileSize"></param>
public TextLogger(string logPathname, WriteCycleExceptionHandler onWriteException, int maxLogFileSize)
{
try
{
OnWriteException = onWriteException;
LogPathname = logPathname;
MaximumLogFileSizeBytes = maxLogFileSize;
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem constructing " + typeof(TextLogger).FullName + " object", ex);
}
}
/// <summary>
/// Write the specified string to the log file.
/// </summary>
///
/// <param name="message">
/// The text <see cref="string"/> to be written to the log file.
/// </param>
///
public void LogMessage(string message)
{
try
{
if (null == CurrentLoggingCycle) { return; }
lock (CurrentLoggingCycle.WriteQueue)
{
CurrentLoggingCycle.WriteQueue.Enqueue($"{message} {Environment.NewLine}");
CurrentLoggingCycle.CycleTrigger.Set();
}
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem logging message " + (null != message ? "\"" + message + "\"" : "<NULL>"), ex);
}
}
/// <summary>
/// Process the remaining items in the queue and then release all resources.
/// </summary>
public void Dispose()
{
try
{
lock (CurrentLoggingCycle.WriteQueue)
EndCurrentWriteCycle();
}
catch (Exception ex)
{
throw new ApplicationException("encountered problem disposing instance of " + typeof(TextLogger).FullName, ex);
}
}
/// <summary>
/// The byte-size at which the log file should be archived.
/// </summary>
public int MaximumLogFileSizeBytes { get; set; } = 1000000;
private readonly Mutex _loggingMutex = new Mutex(false, "SLICEWare_Log");
public volatile bool ReRollLog = false;
/// <summary>
/// Check the size of the specified file and if it exceeds the maximum log file size
/// then give it an archival name and open a new logfile for subsequent data. A
/// cut 'n paste transplant from the original TextLogger.
/// </summary>
///
/// <param name="fileName">
/// The new archival filename <see cref="string"/> for the log file if it exceeds the
/// maximum specified byte-size.
/// </param>
///
protected void CheckForLargeLogFileAndRename(string fileName)
{
if (!File.Exists(fileName))
{
return;
}
var logFileInfo = new FileInfo(fileName);
if (MaximumLogFileSizeBytes >= logFileInfo.Length && !ReRollLog) return;
try
{
ReRollLog = false;
// Set the new file name to the time stamp of the file
var baseFileName = Path.Combine(Path.GetDirectoryName(fileName), Path.GetFileNameWithoutExtension(fileName));
// Rather than rely on locale specific date/time strings, construct it ourself so
// that it will always be yyyy-mm-dd hh.mm. This will cause the file names to sort
// chronologically based on name, and should create a consistent file name result
// for technical support
var dateTimeStamp = logFileInfo.LastWriteTime.ToString("yyyy-MM-dd HH.mm.ss");
var tempFileName = baseFileName + "TEMP";
if (File.Exists(tempFileName)) { File.Delete(tempFileName); }
File.Move(fileName, tempFileName);
var newFileName = baseFileName + " " + dateTimeStamp + ".log.bz2";
try
{
using (var fs = new FileStream(newFileName, FileMode.Create))
{
using (var fs2 = new FileStream(tempFileName, FileMode.Open))
{
ICSharpCode.SharpZipLib.BZip2.BZip2.Compress(fs2, fs, true, 9);
}
}
File.Delete(tempFileName);
}
catch (Exception)
{
newFileName = baseFileName + " " + dateTimeStamp + Path.GetExtension(fileName);
try { File.Move(fileName, newFileName); }
catch (Exception)
{
//ignore
}
}
}
catch (Exception)
{
var newName = fileName + ".LOGERROR";
try { File.Move(fileName, newName); }
catch (Exception)
{
//ignore
}
}
}
}
}