// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace EpicGames.Core
{
///
/// Log Event Type
///
#pragma warning disable CA1027 // Mark enums with FlagsAttribute
public enum LogEventType
#pragma warning restore CA1027 // Mark enums with FlagsAttribute
{
///
/// The log event is a fatal error
///
Fatal = LogLevel.Critical,
///
/// The log event is an error
///
Error = LogLevel.Error,
///
/// The log event is a warning
///
Warning = LogLevel.Warning,
///
/// Output the log event to the console
///
Console = LogLevel.Information,
///
/// Output the event to the on-disk log
///
Log = LogLevel.Debug,
///
/// The log event should only be displayed if verbose logging is enabled
///
Verbose = LogLevel.Trace,
///
/// The log event should only be displayed if very verbose logging is enabled
///
#pragma warning disable CA1069 // Enums values should not be duplicated
VeryVerbose = LogLevel.Trace
#pragma warning restore CA1069 // Enums values should not be duplicated
}
///
/// Options for formatting messages
///
[Flags]
public enum LogFormatOptions
{
///
/// Format normally
///
None = 0,
///
/// Never write a severity prefix. Useful for pre-formatted messages that need to be in a particular format for, eg. the Visual Studio output window
///
NoSeverityPrefix = 1,
///
/// Do not output text to the console
///
NoConsoleOutput = 2,
}
///
/// UAT/UBT Custom log system.
///
/// This lets you use any TraceListeners you want, but you should only call the static
/// methods below, not call Trace.XXX directly, as the static methods
/// This allows the system to enforce the formatting and filtering conventions we desire.
///
/// For posterity, we cannot use the Trace or TraceSource class directly because of our special log requirements:
/// 1. We possibly capture the method name of the logging event. This cannot be done as a macro, so must be done at the top level so we know how many layers of the stack to peel off to get the real function.
/// 2. We have a verbose filter we would like to apply to all logs without having to have each listener filter individually, which would require our string formatting code to run every time.
/// 3. We possibly want to ensure severity prefixes are logged, but Trace.WriteXXX does not allow any severity info to be passed down.
///
public static class Log
{
///
/// Singleton instance of the default output logger
///
private static readonly DefaultLogger s_defaultLogger = new DefaultLogger();
///
/// Logger instance which parses events and forwards them to the main logger.
///
private static readonly LegacyEventLogger s_legacyLogger = new LegacyEventLogger(s_defaultLogger);
///
/// Accessor for the global event parser from legacy events
///
public static LogEventParser EventParser => s_legacyLogger.Parser;
///
/// Logger instance
///
public static ILogger Logger => s_legacyLogger;
///
/// When true, verbose logging is enabled.
///
public static LogEventType OutputLevel
{
get => (LogEventType)s_defaultLogger.OutputLevel;
set => s_defaultLogger.OutputLevel = (LogLevel)value;
}
///
/// Whether to include timestamps on each line of log output
///
public static bool IncludeTimestamps
{
get => s_defaultLogger.IncludeTimestamps;
set => s_defaultLogger.IncludeTimestamps = value;
}
///
/// When true, warnings and errors will have a WARNING: or ERROR: prefix, respectively.
///
public static bool IncludeSeverityPrefix { get; set; } = true;
///
/// When true, warnings and errors will have a prefix suitable for display by MSBuild (avoiding error messages showing as (EXEC : Error : ")
///
public static bool IncludeProgramNameWithSeverityPrefix { get; set; }
///
/// When true, will detect warnings and errors and set the console output color to yellow and red.
///
public static bool ColorConsoleOutput
{
get => s_defaultLogger.ColorConsoleOutput;
set => s_defaultLogger.ColorConsoleOutput = value;
}
///
/// When true, a timestamp will be written to the log file when the first listener is added
///
public static bool IncludeStartingTimestamp
{
get => s_defaultLogger.IncludeStartingTimestamp;
set => s_defaultLogger.IncludeStartingTimestamp = value;
}
///
/// When true, create a backup of any log file that would be overwritten by a new log
/// Log.txt will be backed up with its UTC creation time in the name e.g.
/// Log-backup-2021.10.29-19.53.17.txt
///
public static bool BackupLogFiles { get; set; } = true;
///
/// The number of backups to be preserved - when there are more than this, the oldest backups will be deleted.
/// Backups will not be deleted if BackupLogFiles is false.
///
public static int LogFileBackupCount { get; set; } = 10;
///
/// Path to the log file being written to. May be null.
///
public static FileReference? OutputFile => s_defaultLogger?.OutputFile;
///
/// A collection of strings that have been already written once
///
private static readonly ConcurrentDictionary s_writeOnceSet = new();
///
/// Overrides the logger used for formatting output, after event parsing
///
///
public static void SetInnerLogger(ILogger logger)
{
s_legacyLogger.SetInnerLogger(logger);
}
///
/// Flush the current log output
///
///
public static async Task FlushAsync()
{
await s_defaultLogger.FlushAsync();
}
///
/// Backup an existing log file if it already exists at the outputpath
///
/// The file to back up
public static void BackupLogFile(FileReference outputFile)
{
if (!Log.BackupLogFiles || !FileReference.Exists(outputFile))
{
return;
}
// before creating a new backup, cap the number of existing files
string filenameWithoutExtension = outputFile.GetFileNameWithoutExtension();
string extension = outputFile.GetExtension();
Regex backupForm =
new Regex(filenameWithoutExtension + @"-backup-\d\d\d\d\.\d\d\.\d\d-\d\d\.\d\d\.\d\d" + extension);
foreach (FileReference oldBackup in DirectoryReference
.EnumerateFiles(outputFile.Directory)
// find files that match the way that we name backup files
.Where(x => backupForm.IsMatch(x.GetFileName()))
// sort them from newest to oldest
.OrderByDescending(x => x.GetFileName())
// skip the newest ones that are to be kept; -1 because we're about to create another backup.
.Skip(Log.LogFileBackupCount - 1))
{
Logger.LogDebug("Deleting old log file: {File}", oldBackup);
FileReference.Delete(oldBackup);
}
// Ensure that the backup gets a unique name, in the extremely unlikely case that UBT was run twice during
// the same second.
DateTime fileTime = File.GetCreationTimeUtc(outputFile.FullName);
FileReference backupFile;
for (; ; )
{
string timestamp = $"{fileTime:yyyy.MM.dd-HH.mm.ss}";
backupFile = FileReference.Combine(outputFile.Directory,
$"{filenameWithoutExtension}-backup-{timestamp}{extension}");
if (!FileReference.Exists(backupFile))
{
break;
}
fileTime = fileTime.AddSeconds(1);
}
FileReference.Move(outputFile, backupFile);
}
///
/// Adds a trace listener that writes to a log file.
/// If a StartupTraceListener was in use, this function will copy its captured data to the log file(s)
/// and remove the startup listener from the list of registered listeners.
///
/// Identifier for the writer
/// The file to write to
public static void AddFileWriter(string name, FileReference outputFile)
{
Logger.LogInformation("Log file: {OutputFile}", outputFile);
BackupLogFile(outputFile);
AddFileWriterWithoutBackup(name, outputFile);
}
///
/// Adds a trace listener that writes to a log file.
/// If a StartupTraceListener was in use, this function will copy its captured data to the log file(s)
/// and remove the startup listener from the list of registered listeners.
///
/// Identifier for the writer
/// The file to write to
public static void AddFileWriterWithoutBackup(string name, FileReference outputFile)
{
s_defaultLogger.AddFileWriterWithoutBackup(name, outputFile);
}
///
/// Adds a to the collection in a safe manner.
///
/// The to add.
public static void AddTraceListener(TraceListener traceListener)
{
s_defaultLogger.AddTraceListener(traceListener);
}
///
/// Removes a from the collection in a safe manner.
///
/// The to remove.
public static void RemoveTraceListener(TraceListener traceListener)
{
s_defaultLogger.RemoveTraceListener(traceListener);
}
///
/// Removes a from the collection in a safe manner.
///
public static void RemoveStartupTraceListener()
{
s_defaultLogger.RemoveStartupTraceListener();
}
///
/// Determines if a TextWriterTraceListener has been added to the list of trace listeners
///
/// True if a TextWriterTraceListener has been added
public static bool HasFileWriter()
{
return s_defaultLogger.HasFileWriter();
}
///
/// Converts a LogEventType into a log prefix. Only used when bLogSeverity is true.
///
///
///
private static string GetSeverityPrefix(LogEventType severity)
{
switch (severity)
{
case LogEventType.Fatal:
return "FATAL ERROR: ";
case LogEventType.Error:
return "ERROR: ";
case LogEventType.Warning:
return "WARNING: ";
case LogEventType.Console:
return "";
case LogEventType.Verbose:
return "VERBOSE: ";
default:
return "";
}
}
///
/// Writes a formatted message to the console. All other functions should boil down to calling this method.
///
/// If true, this message will be written only once
/// Message verbosity level. We only meaningfully use values up to Verbose
/// Options for formatting messages
/// Message format string.
/// Optional arguments
[StringFormatMethod("Format")]
private static void WriteLinePrivate(bool bWriteOnce, LogEventType verbosity, LogFormatOptions formatOptions, string format, params object?[] args)
{
if (Logger.IsEnabled((LogLevel)verbosity))
{
StringBuilder message = new StringBuilder();
// Get the severity prefix for this message
if (IncludeSeverityPrefix && ((formatOptions & LogFormatOptions.NoSeverityPrefix) == 0))
{
message.Append(GetSeverityPrefix(verbosity));
if (message.Length > 0 && IncludeProgramNameWithSeverityPrefix)
{
// Include the executable name when running inside MSBuild. If unspecified, MSBuild re-formats them with an "EXEC :" prefix.
message.Insert(0, $"{Path.GetFileNameWithoutExtension(Assembly.GetEntryAssembly()!.Location)}: ");
}
}
// Append the formatted string
int indentLen = message.Length;
if (args.Length == 0)
{
message.Append(format);
}
else
{
message.AppendFormat(format, args);
}
// Replace any Windows \r\n sequences with \n
message.Replace("\r\n", "\n");
// Remove any trailing whitespace
int trimLen = message.Length;
while (trimLen > 0 && " \t\r\n".Contains(message[trimLen - 1], StringComparison.Ordinal))
{
trimLen--;
}
message.Remove(trimLen, message.Length - trimLen);
// Update the indent length to include any whitespace at the start of the message
while (indentLen < message.Length && message[indentLen] == ' ')
{
indentLen++;
}
// If there are multiple lines, insert a prefix at the start of each one
for (int idx = 0; idx < message.Length; idx++)
{
if (message[idx] == '\n')
{
message.Insert(idx + 1, " ", indentLen);
idx += indentLen;
}
}
// if we want this message only written one time, check if it was already written out
if (bWriteOnce && !s_writeOnceSet.TryAdd(message.ToString(), true))
{
return;
}
// Forward it on to the internal logger
if (verbosity < LogEventType.Console)
{
Logger.Log((LogLevel)verbosity, "{Message}", message.ToString());
}
else
{
lock (EventParser)
{
int baseIdx = 0;
for (int idx = 0; idx < message.Length; idx++)
{
if (message[idx] == '\n')
{
EventParser.WriteLine(message.ToString(baseIdx, idx - baseIdx));
baseIdx = idx + 1;
}
}
EventParser.WriteLine(message.ToString(baseIdx, message.Length - baseIdx));
}
}
}
}
///
/// Similar to Trace.WriteLineIf
///
///
///
///
///
[StringFormatMethod("Format")]
public static void WriteLineIf(bool condition, LogEventType verbosity, string format, params object?[] args)
{
if (condition)
{
WriteLinePrivate(false, verbosity, LogFormatOptions.None, format, args);
}
}
///
/// Similar to Trace.WriteLine
///
///
///
///
[StringFormatMethod("Format")]
public static void WriteLine(LogEventType verbosity, string format, params object?[] args)
{
WriteLinePrivate(false, verbosity, LogFormatOptions.None, format, args);
}
///
/// Similar to Trace.WriteLine
///
///
///
///
///
[StringFormatMethod("Format")]
public static void WriteLine(LogEventType verbosity, LogFormatOptions formatOptions, string format, params object?[] args)
{
WriteLinePrivate(false, verbosity, formatOptions, format, args);
}
///
/// Formats an exception for display in the log. The exception message is shown as an error, and the stack trace is included in the log.
///
/// The exception to display
/// The log filename to display, if any
public static void WriteException(Exception ex, FileReference? logFileName)
{
string logSuffix = (logFileName == null) ? "" : String.Format("\n(see {0} for full exception trace)", logFileName);
Logger.LogDebug("==============================================================================");
Logger.LogError(ex, "{Message}{Suffix}", ExceptionUtils.FormatException(ex), logSuffix);
Logger.LogDebug("");
Logger.LogDebug("{Details}", ExceptionUtils.FormatExceptionDetails(ex));
Logger.LogDebug("==============================================================================");
}
///
/// Writes an error message to the console.
///
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
[Obsolete("Use Logger.LogError with a message template instead; see https://tinyurl.com/bp96bk2r.", false)]
public static void TraceError(string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Error, LogFormatOptions.None, format, args);
}
///
/// Writes an error message to the console, in a format suitable for Visual Studio to parse.
///
/// The file containing the error
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceErrorTask(FileReference file, string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Error, LogFormatOptions.NoSeverityPrefix, "{0}: error: {1}", file, String.Format(format, args));
}
///
/// Writes an error message to the console, in a format suitable for Visual Studio to parse.
///
/// The file containing the error
/// Line number of the error
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceErrorTask(FileReference file, int line, string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Error, LogFormatOptions.NoSeverityPrefix, "{0}({1}): error: {2}", file, line, String.Format(format, args));
}
///
/// Writes a verbose message to the console.
///
/// Message format string
/// Optional arguments
[Conditional("TRACE")]
[StringFormatMethod("Format")]
[Obsolete("Use Logger.LogDebug with a message template instead; see https://tinyurl.com/bp96bk2r.", false)]
public static void TraceVerbose(string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Verbose, LogFormatOptions.None, format, args);
}
///
/// Writes a message to the console.
///
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
[Obsolete("Use Logger.LogInformation with a message template instead; see https://tinyurl.com/bp96bk2r.", false)]
public static void TraceInformation(string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Console, LogFormatOptions.None, format, args);
}
///
/// Writes a warning message to the console.
///
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
[Obsolete("Use Logger.LogWarning with a message template instead; see https://tinyurl.com/bp96bk2r.", false)]
public static void TraceWarning(string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Warning, LogFormatOptions.None, format, args);
}
///
/// Writes a warning message to the console, in a format suitable for Visual Studio to parse.
///
/// The file containing the warning
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceWarningTask(FileReference file, string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Warning, LogFormatOptions.NoSeverityPrefix, "{0}: warning: {1}", file, String.Format(format, args));
}
///
/// Writes a warning message to the console, in a format suitable for Visual Studio to parse.
///
/// The file containing the warning
/// Line number of the warning
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceWarningTask(FileReference file, int line, string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Warning, LogFormatOptions.NoSeverityPrefix, "{0}({1}): warning: {2}", file, line, String.Format(format, args));
}
///
/// Writes a message to the console.
///
/// The file containing the message
/// Line number of the message
/// Message format string
/// Optional arguments
public static void TraceConsoleTask(FileReference file, int line, string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Console, LogFormatOptions.NoSeverityPrefix, "{0}({1}): {2}", file, line, String.Format(format, args));
}
///
/// Writes a very verbose message to the console.
///
/// Message format string
/// Optional arguments
[Conditional("TRACE")]
[StringFormatMethod("Format")]
[Obsolete("Use Logger.LogTrace with a message template instead; see https://tinyurl.com/bp96bk2r.", false)]
public static void TraceVeryVerbose(string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.VeryVerbose, LogFormatOptions.None, format, args);
}
///
/// Writes a message to the log only.
///
/// Message format string
/// Optional arguments
[Conditional("TRACE")]
[StringFormatMethod("Format")]
[Obsolete("Use Logger.LogDebug with a message template instead; see https://tinyurl.com/bp96bk2r.", false)]
public static void TraceLog(string format, params object?[] args)
{
WriteLinePrivate(false, LogEventType.Log, LogFormatOptions.None, format, args);
}
///
/// Similar to Trace.WriteLine
///
///
///
///
[StringFormatMethod("Format")]
public static void WriteLineOnce(LogEventType verbosity, string format, params object?[] args)
{
WriteLinePrivate(true, verbosity, LogFormatOptions.None, format, args);
}
///
/// Similar to Trace.WriteLine
///
///
///
///
///
[StringFormatMethod("Format")]
public static void WriteLineOnce(LogEventType verbosity, LogFormatOptions options, string format, params object?[] args)
{
WriteLinePrivate(true, verbosity, options, format, args);
}
///
/// Writes an error message to the console.
///
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceErrorOnce(string format, params object?[] args)
{
WriteLinePrivate(true, LogEventType.Error, LogFormatOptions.None, format, args);
}
///
/// Writes a verbose message to the console.
///
/// Message format string
/// Optional arguments
[Conditional("TRACE")]
[StringFormatMethod("Format")]
public static void TraceVerboseOnce(string format, params object?[] args)
{
WriteLinePrivate(true, LogEventType.Verbose, LogFormatOptions.None, format, args);
}
///
/// Writes a message to the console.
///
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceInformationOnce(string format, params object?[] args)
{
WriteLinePrivate(true, LogEventType.Console, LogFormatOptions.None, format, args);
}
///
/// Writes a warning message to the console.
///
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceWarningOnce(string format, params object?[] args)
{
WriteLinePrivate(true, LogEventType.Warning, LogFormatOptions.None, format, args);
}
///
/// Writes a warning message to the console.
///
/// The file containing the error
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceWarningOnce(FileReference file, string format, params object?[] args)
{
WriteLinePrivate( true, LogEventType.Warning, LogFormatOptions.NoSeverityPrefix, "{0}: warning: {1}", file, String.Format(format, args));
}
///
/// Writes a warning message to the console.
///
/// The file containing the error
/// Line number of the error
/// Message format string
/// Optional arguments
[StringFormatMethod("Format")]
public static void TraceWarningOnce(FileReference file, int line, string format, params object?[] args)
{
WriteLinePrivate(true, LogEventType.Warning, LogFormatOptions.NoSeverityPrefix, "{0}({1}): warning: {2}", file, line, String.Format(format, args));
}
///
/// Writes a very verbose message to the console.
///
/// Message format string
/// Optional arguments
[Conditional("TRACE")]
[StringFormatMethod("Format")]
public static void TraceVeryVerboseOnce(string format, params object?[] args)
{
WriteLinePrivate(true, LogEventType.VeryVerbose, LogFormatOptions.None, format, args);
}
///
/// Writes a message to the log only.
///
/// Message format string
/// Optional arguments
[Conditional("TRACE")]
[StringFormatMethod("Format")]
public static void TraceLogOnce(string format, params object?[] args)
{
WriteLinePrivate(true, LogEventType.Log, LogFormatOptions.None, format, args);
}
///
/// Enter a scope with the given status message. The message will be written to the console without a newline, allowing it to be updated through subsequent calls to UpdateStatus().
/// The message will be written to the log immediately. If another line is written while in a status scope, the initial status message is flushed to the console first.
///
/// The status message
[Conditional("TRACE")]
public static void PushStatus(string message)
{
s_defaultLogger.PushStatus(message);
}
///
/// Updates the current status message. This will overwrite the previous status line.
///
/// The status message
[Conditional("TRACE")]
public static void UpdateStatus(string message)
{
s_defaultLogger.UpdateStatus(message);
}
///
/// Updates the Pops the top status message from the stack. The mess
///
[Conditional("TRACE")]
public static void PopStatus()
{
s_defaultLogger.PopStatus();
}
}
///
/// Base implementation for loggers which handles creation of LogEvents from log commands
///
public sealed class LoggerScopeCollection
{
class Scope : IDisposable
{
readonly LoggerScopeCollection _outer;
readonly IEnumerable>? _properties;
public IEnumerable>? Properties => _properties;
public Scope(LoggerScopeCollection outer, object? value)
{
_outer = outer;
_properties = value switch
{
IEnumerable> props => props,
_ => null
};
_outer._scopes.Add(this);
}
public void Dispose()
=> _outer._scopes.Remove(this);
}
readonly List _scopes = [];
///
/// Tests whether the collection is empty
///
public bool IsEmpty()
=> _scopes.Count == 0;
///
/// Creates a scope with the given state
///
public IDisposable BeginScope(TState state)
=> new Scope(this, state);
///
/// Enumerates all current properties
///
public IEnumerable> GetProperties()
{
foreach (Scope scope in _scopes)
{
if (scope.Properties != null)
{
foreach (KeyValuePair property in scope.Properties)
{
yield return property;
}
}
}
}
}
///
/// Logger which captures the output for rendering later
///
public class CaptureLogger : ILogger
{
readonly LoggerScopeCollection _scopeCollection = new LoggerScopeCollection();
///
/// List of captured events
///
public List Events { get; } = [];
///
/// Renders the captured events as a single string
///
/// Rendered log text
public string Render() => Render("\n");
///
/// Renders the captured events as a single string
///
/// Rendered log text
public string Render(string newLine) => String.Join(newLine, RenderLines());
///
/// Renders all the captured events
///
/// List of rendered log lines
public List RenderLines() => Events.ConvertAll(x => x.ToString());
///
/// Renders all the captured events to another logger
///
///
public void RenderTo(ILogger logger) => Events.ForEach( x => logger.Log(x.Level, x.Id, x, null, (s, e) => s.ToString()) );
///
public IDisposable? BeginScope(TState state) where TState : notnull => _scopeCollection.BeginScope(state);
///
public bool IsEnabled(LogLevel logLevel) => true;
///
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
LogEvent logEvent = LogEvent.FromState(logLevel, eventId, state, exception, formatter);
logEvent.AddProperties(_scopeCollection.GetProperties());
Events.Add(logEvent);
}
}
///
/// Wrapper around a custom logger interface which flushes the event parser when switching between legacy
/// and native structured logging
///
sealed class LegacyEventLogger : ILogger, IDisposable
{
private ILogger _inner;
private readonly LogEventParser _parser;
public LogEventParser Parser => _parser;
public LegacyEventLogger(ILogger inner)
{
_inner = inner;
_parser = new LogEventParser(inner);
}
public void Dispose()
{
_parser.Dispose();
}
public void SetInnerLogger(ILogger inner)
{
lock (_parser)
{
_parser.Flush();
_inner = inner;
_parser.Logger = inner;
}
}
public IDisposable? BeginScope(TState state) where TState : notnull => _inner.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => _inner.IsEnabled(logLevel);
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
lock (_parser)
{
_parser.Flush();
}
_inner.Log(logLevel, eventId, state, exception, formatter);
}
}
///
/// Default log output device
///
class DefaultLogger : ILogger, IDisposable
{
///
/// Temporary status message displayed on the console.
///
[DebuggerDisplay("{HeadingText}")]
class StatusMessage
{
///
/// The heading for this status message.
///
public string _headingText;
///
/// The current status text.
///
public string _currentText;
///
/// Whether the heading has been written to the console. Before the first time that lines are output to the log in the midst of a status scope, the heading will be written on a line of its own first.
///
public bool _hasFlushedHeadingText;
///
/// Constructor
///
public StatusMessage(string headingText, string currentText)
{
_headingText = headingText;
_currentText = currentText;
}
}
private readonly LoggerScopeCollection _scopeCollection = new LoggerScopeCollection();
private readonly object _syncObject = new object();
///
/// Minimum level for outputting messages
///
public LogLevel OutputLevel
{
get; set;
}
///
/// Whether to include timestamps on each line of log output
///
public bool IncludeTimestamps
{
get; set;
}
///
/// When true, will detect warnings and errors and set the console output color to yellow and red.
///
public bool ColorConsoleOutput
{
get; set;
}
///
/// Whether to write JSON to stdout
///
public bool WriteJsonToStdOut
{
get; set;
}
///
/// When true, a timestamp will be written to the log file when the first listener is added
///
public bool IncludeStartingTimestamp
{
get; set;
}
private bool _includeStartingTimestampWritten = false;
///
/// Path to the log file being written to. May be null.
///
public FileReference? OutputFile
{
get; private set;
}
///
/// Whether console output is redirected. This prevents writing status updates that rely on moving the cursor.
///
private static bool AllowStatusUpdates { get; set; } = !Console.IsOutputRedirected;
///
/// When configured, this tracks time since initialization to prepend a timestamp to each log.
///
private readonly Stopwatch _timer = Stopwatch.StartNew();
///
/// Stack of status scope information.
///
private readonly Stack _statusMessageStack = new Stack();
///
/// The currently visible status text
///
private string _statusText = "";
///
/// Parser for transforming legacy log output into structured events
///
public LogEventParser EventParser { get; }
///
/// Last time a status message was pushed to the stack
///
private readonly Stopwatch _statusTimer = new Stopwatch();
///
/// Background task for writing to files
///
private Task _writeTask;
///
/// Channel for new log events
///
private Channel _eventChannel = Channel.CreateUnbounded();
///
/// Output streams for structured log data
///
private IReadOnlyList _jsonStreams = [];
///
/// Constructor
///
public DefaultLogger()
{
OutputLevel = LogLevel.Debug;
ColorConsoleOutput = true;
IncludeStartingTimestamp = true;
EventParser = new LogEventParser(this);
string? envVar = Environment.GetEnvironmentVariable("UE_LOG_JSON_TO_STDOUT");
if(envVar != null && Int32.TryParse(envVar, out int value) && value != 0)
{
WriteJsonToStdOut = true;
}
_writeTask = Task.Run(() => WriteFilesAsync());
}
///
public void Dispose()
{
_eventChannel.Writer.TryComplete();
_writeTask.Wait();
}
///
/// Flush the stream
///
///
public async Task FlushAsync()
{
lock (_syncObject)
{
Channel prevEventChannel = _eventChannel;
_eventChannel = Channel.CreateUnbounded();
prevEventChannel.Writer.TryComplete();
}
await _writeTask;
_writeTask = Task.Run(() => WriteFilesAsync());
}
///
/// Background task to write events to sinks
///
async Task WriteFilesAsync()
{
byte[] newline = [(byte)'\n'];
while (await _eventChannel.Reader.WaitToReadAsync())
{
IReadOnlyList streams = _jsonStreams;
JsonLogEvent logEvent;
while (_eventChannel.Reader.TryRead(out logEvent))
{
foreach (FileStream stream in streams)
{
await stream.WriteAsync(logEvent.Data);
await stream.WriteAsync(newline);
}
}
foreach (FileStream stream in streams)
{
await stream.FlushAsync();
}
}
}
///
/// Adds a trace listener that writes to a log file
///
/// Listener name
/// The file to write to
/// The created trace listener
public TextWriterTraceListener AddFileWriter(string name, FileReference outputFile)
{
try
{
OutputFile = outputFile;
DirectoryReference.CreateDirectory(outputFile.Directory);
TextWriterTraceListener logTraceListener = new TextWriterTraceListener(new StreamWriter(outputFile.FullName), name);
lock (_syncObject)
{
Trace.Listeners.Add(logTraceListener);
WriteInitialTimestamp();
List newJsonStreams = new List(_jsonStreams);
newJsonStreams.Add(FileReference.Open(outputFile.ChangeExtension(".json"), FileMode.Create, FileAccess.Write, FileShare.Read | FileShare.Delete));
_jsonStreams = newJsonStreams;
}
return logTraceListener;
}
catch (Exception ex)
{
throw new Exception($"Error while creating log file \"{outputFile}\"", ex);
}
}
///
/// Adds a to the collection in a safe manner.
///
/// The to add.
public void AddTraceListener(TraceListener traceListener)
{
lock (_syncObject)
{
if (!Trace.Listeners.Contains(traceListener))
{
Trace.Listeners.Add(traceListener);
WriteInitialTimestamp();
}
}
}
///
/// Write a timestamp to the log, once. To be called when a new listener is added.
///
private void WriteInitialTimestamp()
{
if (IncludeStartingTimestamp && !_includeStartingTimestampWritten)
{
DateTime now = DateTime.Now;
this.LogDebug("{Message}", $"Log started at {now} ({now.ToUniversalTime():yyyy-MM-ddTHH\\:mm\\:ssZ})");
_includeStartingTimestampWritten = true;
}
}
///
/// Removes a from the collection in a safe manner.
///
/// The to remove.
public void RemoveTraceListener(TraceListener traceListener)
{
lock (_syncObject)
{
if (Trace.Listeners.Contains(traceListener))
{
Trace.Listeners.Remove(traceListener);
}
}
}
///
/// Removes from the collection in a safe manner.
///
public void RemoveStartupTraceListener()
{
lock (_syncObject)
{
IEnumerable startupListeners = Trace.Listeners.OfType();
if (startupListeners.Any())
{
Trace.Listeners.Remove(startupListeners.First());
}
}
}
///
/// Adds a trace listener that writes to a log file.
/// If a StartupTraceListener was in use, this function will copy its captured data to the log file(s)
/// and remove the startup listener from the list of registered listeners.
///
/// Identifier for the writer
/// The file to write to
public void AddFileWriterWithoutBackup(string name, FileReference outputFile)
{
TextWriterTraceListener firstTextWriter = AddFileWriter(name, outputFile);
lock (_syncObject)
{
// find the StartupTraceListener in the listeners that was added early on
IEnumerable startupListeners = Trace.Listeners.OfType();
if (startupListeners.Any())
{
StartupTraceListener startupListener = startupListeners.First();
startupListener.CopyTo(firstTextWriter);
Trace.Listeners.Remove(startupListeners.First());
}
}
}
///
/// Determines if a TextWriterTraceListener has been added to the list of trace listeners
///
/// True if a TextWriterTraceListener has been added
public bool HasFileWriter()
{
lock (_syncObject)
{
return Trace.Listeners.OfType().Any();
}
}
///
public IDisposable? BeginScope(TState state) where TState : notnull
=> _scopeCollection.BeginScope(state);
///
public bool IsEnabled(LogLevel logLevel)
{
return logLevel >= OutputLevel;
}
///
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
string[] lines = formatter(state, exception).Split('\n');
lock (_syncObject)
{
// Output to all the other trace listeners
string timePrefix = String.Format("[{0:hh\\:mm\\:ss\\.fff}] ", _timer.Elapsed);
foreach (TraceListener? listener in Trace.Listeners)
{
if (listener != null)
{
if (listener is DefaultTraceListener && logLevel < LogLevel.Information)
{
continue;
}
string timePrefixActual =
IncludeTimestamps &&
listener is not DefaultTraceListener // no timestamps when writing to the Visual Studio debug window
? timePrefix
: String.Empty;
foreach (string line in lines)
{
string lineWithTime = timePrefixActual + line;
listener.WriteLine(lineWithTime);
listener.Flush();
}
}
}
Activity? activity = Activity.Current;
JsonLogEvent jsonLogEvent;
if (activity == null && _scopeCollection.IsEmpty())
{
jsonLogEvent = JsonLogEvent.FromLoggerState(logLevel, eventId, state, exception, formatter);
}
else
{
LogEvent logEvent = LogEvent.FromState(logLevel, eventId, state, exception, formatter);
logEvent.AddProperties(_scopeCollection.GetProperties());
if (activity != null)
{
logEvent.AddProperty("Activity", activity);
}
jsonLogEvent = new JsonLogEvent(logEvent);
}
_eventChannel.Writer.TryWrite(jsonLogEvent);
// Handle the console output separately; we format things differently
if (logLevel >= LogLevel.Information)
{
FlushStatusHeading();
bool bResetConsoleColor = false;
if (ColorConsoleOutput)
{
if (logLevel == LogLevel.Warning)
{
Console.ForegroundColor = ConsoleColor.Yellow;
bResetConsoleColor = true;
}
if (logLevel >= LogLevel.Error)
{
Console.ForegroundColor = ConsoleColor.Red;
bResetConsoleColor = true;
}
}
try
{
if (WriteJsonToStdOut)
{
Console.WriteLine(Encoding.UTF8.GetString(jsonLogEvent.Data.Span));
}
else
{
foreach (string line in lines)
{
Console.WriteLine(line);
}
}
}
catch (IOException)
{
// Potential file access/sharing issue on std out
}
finally
{
// make sure we always put the console color back.
if (bResetConsoleColor)
{
Console.ResetColor();
}
}
if (_statusMessageStack.Count > 0 && AllowStatusUpdates)
{
SetStatusText(_statusMessageStack.Peek()._currentText);
}
}
}
}
///
/// Flushes the current status text before writing out a new log line or status message
///
void FlushStatusHeading()
{
if (_statusMessageStack.Count > 0)
{
StatusMessage currentStatus = _statusMessageStack.Peek();
if (currentStatus._headingText.Length > 0 && !currentStatus._hasFlushedHeadingText && AllowStatusUpdates)
{
SetStatusText(currentStatus._headingText);
Console.WriteLine();
_statusText = "";
currentStatus._hasFlushedHeadingText = true;
}
else
{
SetStatusText("");
}
}
}
///
/// Enter a scope with the given status message. The message will be written to the console without a newline, allowing it to be updated through subsequent calls to UpdateStatus().
/// The message will be written to the log immediately. If another line is written while in a status scope, the initial status message is flushed to the console first.
///
/// The status message
[Conditional("TRACE")]
public void PushStatus(string message)
{
lock (_syncObject)
{
FlushStatusHeading();
StatusMessage newStatusMessage = new StatusMessage(message, message);
_statusMessageStack.Push(newStatusMessage);
_statusTimer.Restart();
if (message.Length > 0)
{
this.LogDebug("{Message}", message);
SetStatusText(message);
}
}
}
///
/// Updates the current status message. This will overwrite the previous status line.
///
/// The status message
[Conditional("TRACE")]
public void UpdateStatus(string message)
{
lock (_syncObject)
{
StatusMessage currentStatusMessage = _statusMessageStack.Peek();
currentStatusMessage._currentText = message;
if (AllowStatusUpdates || _statusTimer.Elapsed.TotalSeconds > 10.0)
{
SetStatusText(message);
_statusTimer.Restart();
}
}
}
///
/// Updates the Pops the top status message from the stack. The mess
///
[Conditional("TRACE")]
public void PopStatus()
{
lock (_syncObject)
{
StatusMessage currentStatusMessage = _statusMessageStack.Peek();
SetStatusText(currentStatusMessage._currentText);
if (_statusText.Length > 0)
{
Console.WriteLine();
_statusText = "";
}
_statusMessageStack.Pop();
}
}
///
/// Update the status text. For internal use only; does not modify the StatusMessageStack objects.
///
/// New status text to display
private void SetStatusText(string newStatusText)
{
if (newStatusText.Length > 0)
{
newStatusText = LogIndent.Current + newStatusText;
}
if (_statusText != newStatusText)
{
int numCommonChars = 0;
while (numCommonChars < _statusText.Length && numCommonChars < newStatusText.Length && _statusText[numCommonChars] == newStatusText[numCommonChars])
{
numCommonChars++;
}
if (!AllowStatusUpdates && numCommonChars < _statusText.Length && _statusText.Length > 0)
{
// Prevent writing backspace characters if the console doesn't support it
Console.WriteLine();
_statusText = "";
numCommonChars = 0;
}
StringBuilder text = new StringBuilder();
text.Append('\b', _statusText.Length - numCommonChars);
text.Append(newStatusText, numCommonChars, newStatusText.Length - numCommonChars);
if (newStatusText.Length < _statusText.Length)
{
int numChars = _statusText.Length - newStatusText.Length;
text.Append(' ', numChars);
text.Append('\b', numChars);
}
Console.Write(text.ToString());
_statusText = newStatusText;
_statusTimer.Restart();
}
}
}
///
/// Provider for default logger instances
///
public sealed class DefaultLoggerProvider : ILoggerProvider
{
///
public ILogger CreateLogger(string categoryName)
{
return new DefaultLogger();
}
///
public void Dispose()
{
}
}
///
/// Extension methods to support the default logger
///
public static class DefaultLoggerExtensions
{
///
/// Adds a regular Epic logger to the builder
///
/// Logging builder
public static void AddEpicDefault(this ILoggingBuilder builder)
{
builder.Services.AddSingleton();
}
}
}