// 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(); } } }