Cause: - Live diagnostics required logging from multiple applications without blocking TBF. - Shared nlog.config files caused configuration conflicts. - The original viewer lacked configurable colors and practical log controls. - Inconsistent diagnostic deployment caused log transfer failures and locked shared DLLs during builds. Solution: 1. Added universal diagnostic transport - Added AppDiagnostic.Logging with an NLog target and named-pipe communication. - Added background buffering and automatic reconnection. - Registered the diagnostic connector programmatically. 2. Separated diagnostic window lifecycle - Run AppDiagnostic as an independent process. - Activate the existing window on repeated launches. - Keep TBF running when the diagnostic window closes. 3. Corrected diagnostic deployment - Copy the complete diagnostic output into a dedicated AppDiagnostic subdirectory during build. - Overwrite existing deployment files to prevent inconsistent versions. - Validate and launch the executable from that subdirectory with an explicit working directory. 4. Improved the log viewer - Added configurable substring colors with a palette and live preview. - Added text, level and application filters. - Added line wrapping, clipboard actions and log export. - Added Pause/Resume, Refresh, Clear, auto-scroll and Always on top. - Added connection and log counters. - Persist color rules between sessions. 5. Removed legacy dependencies - Replaced LiveLogCache with structured diagnostic storage. - Removed AppDiagnostic dependencies on log4net and SharedComponents. Validation: - Confirmed successful log transfer when launching AppDiagnostic from TBF.
234 lines
6.8 KiB
C#
234 lines
6.8 KiB
C#
using NLog;
|
|
using NLog.Targets;
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.IO.Pipes;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace AppDiagnostic.Logging
|
|
{
|
|
[Target("AppDiagnostic")]
|
|
public sealed class AppDiagnosticTarget : TargetWithLayout
|
|
{
|
|
private readonly object connectionLock = new object();
|
|
private readonly string applicationName;
|
|
private readonly int processId;
|
|
|
|
private BlockingCollection<DiagnosticLogEntry> queue;
|
|
private CancellationTokenSource cancellation;
|
|
private Task senderTask;
|
|
private NamedPipeClientStream pipeClient;
|
|
|
|
public AppDiagnosticTarget()
|
|
{
|
|
using (Process process = Process.GetCurrentProcess())
|
|
{
|
|
applicationName = process.ProcessName;
|
|
processId = process.Id;
|
|
}
|
|
|
|
PipeName = DiagnosticProtocol.DefaultPipeName;
|
|
QueueLimit = DiagnosticProtocol.DefaultQueueLimit;
|
|
ReconnectDelayMilliseconds =
|
|
DiagnosticProtocol.DefaultReconnectDelayMilliseconds;
|
|
ConnectTimeoutMilliseconds =
|
|
DiagnosticProtocol.DefaultConnectTimeoutMilliseconds;
|
|
}
|
|
|
|
public string PipeName { get; set; }
|
|
|
|
public int QueueLimit { get; set; }
|
|
|
|
public int ReconnectDelayMilliseconds { get; set; }
|
|
|
|
public int ConnectTimeoutMilliseconds { get; set; }
|
|
|
|
protected override void InitializeTarget()
|
|
{
|
|
base.InitializeTarget();
|
|
|
|
int queueLimit = QueueLimit > 0
|
|
? QueueLimit
|
|
: DiagnosticProtocol.DefaultQueueLimit;
|
|
|
|
queue = new BlockingCollection<DiagnosticLogEntry>(queueLimit);
|
|
cancellation = new CancellationTokenSource();
|
|
|
|
senderTask = Task.Factory.StartNew(
|
|
ProcessQueue,
|
|
cancellation.Token,
|
|
TaskCreationOptions.LongRunning,
|
|
TaskScheduler.Default);
|
|
}
|
|
|
|
protected override void Write(LogEventInfo logEvent)
|
|
{
|
|
if (logEvent == null || queue == null || queue.IsAddingCompleted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var entry = new DiagnosticLogEntry
|
|
{
|
|
TimestampUtc = logEvent.TimeStamp.ToUniversalTime(),
|
|
MachineName = Environment.MachineName,
|
|
ApplicationName = applicationName,
|
|
ProcessId = processId,
|
|
LoggerName = logEvent.LoggerName ?? string.Empty,
|
|
Level = logEvent.Level?.Name ?? string.Empty,
|
|
Message = Layout != null
|
|
? Layout.Render(logEvent)
|
|
: logEvent.FormattedMessage,
|
|
Exception = logEvent.Exception?.ToString() ?? string.Empty
|
|
};
|
|
|
|
try
|
|
{
|
|
queue.TryAdd(entry);
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// A concurrent NLog shutdown may dispose the queue.
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// A concurrent NLog shutdown may complete the queue.
|
|
}
|
|
}
|
|
|
|
protected override void CloseTarget()
|
|
{
|
|
BlockingCollection<DiagnosticLogEntry> currentQueue = queue;
|
|
CancellationTokenSource currentCancellation = cancellation;
|
|
|
|
if (currentQueue != null)
|
|
{
|
|
currentQueue.CompleteAdding();
|
|
}
|
|
|
|
if (currentCancellation != null)
|
|
{
|
|
currentCancellation.Cancel();
|
|
}
|
|
|
|
CloseConnection();
|
|
|
|
try
|
|
{
|
|
senderTask?.Wait(2000);
|
|
}
|
|
catch (AggregateException)
|
|
{
|
|
// Cancellation during application shutdown is expected.
|
|
}
|
|
|
|
currentQueue?.Dispose();
|
|
currentCancellation?.Dispose();
|
|
|
|
queue = null;
|
|
cancellation = null;
|
|
senderTask = null;
|
|
|
|
base.CloseTarget();
|
|
}
|
|
|
|
private void ProcessQueue()
|
|
{
|
|
CancellationToken token = cancellation.Token;
|
|
|
|
try
|
|
{
|
|
foreach (DiagnosticLogEntry entry in
|
|
queue.GetConsumingEnumerable(token))
|
|
{
|
|
SendWhenConnected(entry, token);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Target shutdown is expected to cancel the sender.
|
|
}
|
|
}
|
|
|
|
private void SendWhenConnected(
|
|
DiagnosticLogEntry entry,
|
|
CancellationToken token)
|
|
{
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
EnsureConnected();
|
|
DiagnosticMessageSerializer.WriteFramed(pipeClient, entry);
|
|
return;
|
|
}
|
|
catch (Exception exception) when (
|
|
exception is IOException ||
|
|
exception is TimeoutException ||
|
|
exception is InvalidOperationException)
|
|
{
|
|
CloseConnection();
|
|
|
|
int delay = ReconnectDelayMilliseconds > 0
|
|
? ReconnectDelayMilliseconds
|
|
: DiagnosticProtocol.DefaultReconnectDelayMilliseconds;
|
|
|
|
if (token.WaitHandle.WaitOne(delay))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void EnsureConnected()
|
|
{
|
|
lock (connectionLock)
|
|
{
|
|
if (pipeClient != null && pipeClient.IsConnected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
CloseConnectionWithoutLock();
|
|
|
|
pipeClient = new NamedPipeClientStream(
|
|
".",
|
|
string.IsNullOrWhiteSpace(PipeName)
|
|
? DiagnosticProtocol.DefaultPipeName
|
|
: PipeName,
|
|
PipeDirection.Out,
|
|
PipeOptions.Asynchronous);
|
|
|
|
int timeout = ConnectTimeoutMilliseconds > 0
|
|
? ConnectTimeoutMilliseconds
|
|
: DiagnosticProtocol.DefaultConnectTimeoutMilliseconds;
|
|
|
|
pipeClient.Connect(timeout);
|
|
}
|
|
}
|
|
|
|
private void CloseConnection()
|
|
{
|
|
lock (connectionLock)
|
|
{
|
|
CloseConnectionWithoutLock();
|
|
}
|
|
}
|
|
|
|
private void CloseConnectionWithoutLock()
|
|
{
|
|
if (pipeClient == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
pipeClient.Dispose();
|
|
pipeClient = null;
|
|
}
|
|
}
|
|
}
|