Files
UnrealEngine/Engine/Source/Programs/AutomationTool/Gauntlet/Unreal/Utils/Gauntlet.UnrealLogParser.cs
2025-05-18 13:04:45 +08:00

1414 lines
47 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Linq;
using System.Text;
using EpicGames.Core;
using Microsoft.Extensions.Logging;
using Logging = Microsoft.Extensions.Logging;
using AutomationUtils.Matchers;
using AutomationTool;
namespace Gauntlet
{
/// <summary>
/// Represents a prepared summary of the most relevant info in a log. Generated once by a
/// LogParser and cached.
/// </summary>
public class UnrealLog
{
/// <summary>
/// Represents the level
/// </summary>
public enum LogLevel
{
Log,
Display,
Verbose,
VeryVerbose,
Warning,
Error,
Fatal
}
/// <summary>
/// Set of log channels that are used to monitor Editor processing
/// </summary>
public static string[] EditorBusyChannels = new string[] { "Automation", "FunctionalTest", "Material", "DerivedDataCache", "ShaderCompilers", "Texture", "SkeletalMesh", "StaticMesh", "Python" };
/// <summary>
/// Represents an entry in an Unreal logfile with and contails the associated category, level, and message
/// </summary>
public class LogEntry
{
public string Prefix { get; private set; }
/// <summary>
/// Category of the entry. E.g for "LogNet" this will be "Net"
/// </summary>
public string Category { get; private set; }
/// <summary>
/// Full channel name
/// </summary>
public string LongChannelName => Prefix + Category;
/// <summary>
/// Represents the level of the entry
/// </summary>
public LogLevel Level { get; private set; }
/// <summary>
/// The message string from the entry
/// </summary>
public string Message { get; private set; }
/// <summary>
/// Format the entry as it would have appeared in the log.
/// </summary>
/// <returns></returns>
public override string ToString()
{
// LogFoo: Display: Some Message
// Match how Unreal does not display the level for 'Log' level messages
if (Level == LogLevel.Log)
{
return string.Format("{0}: {1}", LongChannelName, Message);
}
else
{
return string.Format("{0}: {1}: {2}", LongChannelName, Level, Message);
}
}
/// <summary>
/// Constructor that requires all info
/// </summary>
/// <param name="InPrefix"></param>
/// <param name="InCategory"></param>
/// <param name="InLevel"></param>
/// <param name="InMessage"></param>
public LogEntry(string InPrefix, string InCategory, LogLevel InLevel, string InMessage)
{
Prefix = InPrefix;
Category = InCategory;
Level = InLevel;
Message = InMessage;
}
}
/// <summary>
/// Compound object that represents a fatal entry
/// </summary>
public class CallstackMessage
{
public int Position;
public string Message;
public string[] Callstack;
public bool IsEnsure;
public bool IsSanReport;
/// <summary>
/// Generate a string that represents a CallstackMessage formatted to be inserted into a log file.
/// </summary>
/// <returns>Formatted log string version of callstack.</returns>
public string FormatForLog()
{
string FormattedString = string.Format("{0}\n", Message);
foreach (string StackLine in Callstack)
{
FormattedString += string.Format("\t{0}\n", StackLine);
}
return FormattedString;
}
};
/// <summary>
/// Information about the current platform that will exist and be extracted from the log
/// </summary>
public class PlatformInfo
{
public string OSName;
public string OSVersion;
public string CPUName;
public string GPUName;
}
/// <summary>
/// Information about the current build info
/// </summary>
public class BuildInfo
{
public string BuildVersion;
public string BranchName;
public int Changelist;
}
private UnrealLogParser _parser;
public UnrealLog(UnrealLogParser InParser)
{
_parser = InParser;
_loggedBuildInfo = new(() => _parser.GetBuildInfo());
_loggedPlatformInfo = new(() => _parser.GetPlatformInfo());
_logEntries = new(() => _parser.LogEntries);
_fatalError = new(() => _parser.GetFatalError());
_ensures = new(() => _parser.GetEnsures());
_lineCount = new(() => _parser.GetLogReader().GetAvailableLineCount());
_hasTestExitCode = new(() => _parser.GetTestExitCode(out _testExitCode));
_engineInitialized = new(() => HasEngineInitialized());
_hasParseRequestedExitReason = new(() => GetRequestedExitReason());
}
private bool GetRequestedExitReason()
{
// Check request exit and reason
_parser.MatchAndApplyGroups(@"Engine exit requested \(reason:\s*(.+)\)", (Groups) =>
{
_requestedExit = true;
_requestedExitReason = Groups[1];
});
if (!_requestedExit)
{
var Completion = _parser.GetAllMatchingLines("F[a-zA-Z0-9]+::RequestExit");
var ErrorCompletion = _parser.GetAllMatchingLines("StaticShutdownAfterError");
if (Completion.Any() || ErrorCompletion.Any())
{
_requestedExit = true;
_requestedExitReason = "Unidentified";
}
}
return true;
}
private bool HasEngineInitialized()
{
// Search for Engine initialized pattern.
return _parser.GetAllMatches(EngineInitializedPattern).Any();
}
/// <summary>
/// Return the log parser attached to it
/// </summary>
/// <returns></returns>
public UnrealLogParser GetParser() => _parser;
/// <summary>
/// Build info from the log
/// </summary>
public BuildInfo LoggedBuildInfo => _loggedBuildInfo.Value;
private Lazy<BuildInfo> _loggedBuildInfo;
/// <summary>
/// Platform info from the log
/// </summary>
public PlatformInfo LoggedPlatformInfo => _loggedPlatformInfo.Value;
private Lazy<PlatformInfo> _loggedPlatformInfo;
/// <summary>
/// Entries in the log
/// </summary>
public IEnumerable<LogEntry> LogEntries { get { return _logEntries.Value; } set { _logEntries = new(value); } }
private Lazy<IEnumerable<LogEntry>> _logEntries;
/// <summary>
/// Warnings for this role
/// </summary>
public IEnumerable<LogEntry> Warnings { get { return LogEntries.Where(E => E.Level == LogLevel.Warning); } }
/// <summary>
/// Errors for this role
/// </summary>
public IEnumerable<LogEntry> Errors { get { return LogEntries.Where(E => E.Level == LogLevel.Error); } }
/// <summary>
/// Fatal error instance if one occurred
/// </summary>
public CallstackMessage FatalError { get { return _fatalError.Value; } set { _fatalError = new(value); } }
private Lazy<CallstackMessage> _fatalError;
/// <summary>
/// A list of ensures if any occurred
/// </summary>
public IEnumerable<CallstackMessage> Ensures { get { return _ensures.Value; } set { _ensures = new(value); } }
private Lazy<IEnumerable<CallstackMessage>> _ensures;
/// <summary>
/// Number of lines in the log
/// </summary>
public int LineCount => _lineCount.Value;
private Lazy<int> _lineCount;
/// <summary>
/// True if the engine reached initialization
/// </summary>
public bool EngineInitialized { get { return _engineInitialized.Value; } set { _engineInitialized = new(() => value); } }
private Lazy<bool> _engineInitialized;
/// <summary>
/// Regex pattern used to detect if the engine was initialized
/// </summary>
public string EngineInitializedPattern = @"LogInit.+Engine is initialized\.";
/// <summary>
/// True if the instance requested exit
/// </summary>
public bool RequestedExit { get { return _hasParseRequestedExitReason.Value ? _requestedExit : false; } set { _requestedExit = value; _hasParseRequestedExitReason = new(() => true); } }
private bool _requestedExit;
public string RequestedExitReason { get { return _hasParseRequestedExitReason.Value ? _requestedExitReason : string.Empty; } set { _requestedExitReason = value; _hasParseRequestedExitReason = new(() => true); } }
private string _requestedExitReason;
private Lazy<bool> _hasParseRequestedExitReason;
public bool HasTestExitCode { get { return _hasTestExitCode.Value; } set { _hasTestExitCode = new(() => value); } }
private Lazy<bool> _hasTestExitCode;
public int TestExitCode { get { return HasTestExitCode ? _testExitCode : -1; } set { _testExitCode = value; } }
protected int _testExitCode;
// DEPRECATED - it is slow. Get attached parser instead through call of GetParser()
public string FullLogContent => _parser.GetLogReader().GetContent();
/// <summary>
/// Returns true if this log indicates the Unreal instance exited abnormally
/// </summary>
public bool HasAbnormalExit
{
get
{
return FatalError != null
|| EngineInitialized == false
|| (RequestedExit == false && HasTestExitCode == false);
}
}
}
/// <summary>
/// Parse Unreal log from string chunk and aggregate lines as LogEvents
/// Support structure logging output and legacy logging style
/// </summary>
public class UnrealLogStreamParser
{
protected List<UnrealLog.LogEntry> LogEvents { get; private set; }
private HashSet<string> UnidentifiedLogLevels { get; set; }
private ILogStreamReader LogReader { get; set; }
public UnrealLogStreamParser()
{
LogEvents = new();
UnidentifiedLogLevels = new HashSet<string>();
LogReader = null;
}
public UnrealLogStreamParser(ILogStreamReader InLogReader)
: this()
{
LogReader = InLogReader;
}
/// <summary>
/// Set the internal log stream reader
/// </summary>
/// <param name="InLogReader"></param>
public void SetLogReader(ILogStreamReader InLogReader)
{
LogReader = InLogReader;
}
/// <summary>
/// Return true if the internal log reader was set.
/// </summary>
/// <returns></returns>
public bool IsAttachedToLogReader()
{
return LogReader != null;
}
/// <summary>
/// Clear aggregated log events
/// </summary>
public void Clear()
{
LogEvents.Clear();
}
/// <summary>
/// Parse a string as log and aggregate identified unreal log lines using internal Log reader
/// </summary>
/// <param name="LineOffset">Line offset to start parsing and aggregate. Default is set to use the internal LogReader cursor.</param>
/// <param name="bClearAggregatedLines">Whether to clear the previously aggregated lines</param>
/// <returns>The number of line parsed</returns>
public int ReadStream(int LineOffset = -1, bool bClearAggregatedLines = true)
{
if (!IsAttachedToLogReader())
{
throw new AutomationException("Internal Log reader is not set. Use SetLogReader() to set it.");
}
return ReadStream(LogReader, LineOffset, bClearAggregatedLines);
}
/// <summary>
/// Parse a string as log and aggregate identified unreal log lines
/// </summary>
/// <param name="InContent"></param>
/// <param name="LineOffset">Line offset to start parsing and aggregate. By passing -1, it will use the internal LogReader cursor.</param>
/// <param name="bClearAggregatedLines">Whether to clear the previously aggregated lines</param>
/// <returns>The number of line parsed</returns>
public int ReadStream(string InContent, int LineOffset = 0, bool bClearAggregatedLines = true)
{
return ReadStream(new DynamicStringReader(() => InContent), LineOffset, bClearAggregatedLines);
}
/// <summary>
/// Parse a string as log and aggregate identified unreal log lines
/// </summary>
/// <param name="LogReader"></param>
/// <param name="LineOffset">Line offset to start parsing and aggregate. By passing -1, it will use the internal LogReader cursor.</param>
/// <param name="bClearAggregatedLines">Whether to clear the previously aggregated lines</param>
/// <returns>The number of line parsed</returns>
public int ReadStream(ILogStreamReader LogReader, int LineOffset = 0, bool bClearAggregatedLines = true)
{
if (bClearAggregatedLines)
{
Clear();
}
Regex UELogLinePattern = new Regex(@"(?<channel>[A-Za-z][\w\d]+):\s(?:(?<level>Display|Verbose|VeryVerbose|Warning|Error|Fatal):\s)?");
if (LineOffset >= 0)
{
LogReader.SetLineIndex(LineOffset);
}
foreach(string Line in LogReader.EnumerateNextLines())
{
UnrealLog.LogEntry Entry = null;
// Parse the line as Unreal legacy line
Match MatchLine = UELogLinePattern.Match(Line);
if (MatchLine.Success)
{
string Channel = MatchLine.Groups["channel"].Value;
string Message = Line.Substring(MatchLine.Index + MatchLine.Length);
ReadOnlySpan<char> LevelSpan = MatchLine.Groups["level"].ValueSpan;
UnrealLog.LogLevel Level = UnrealLog.LogLevel.Log;
if (!LevelSpan.IsEmpty)
{
if (!Enum.TryParse(LevelSpan, out Level))
{
string LevelStr = LevelSpan.ToString();
// only show a warning once
if (!UnidentifiedLogLevels.Contains(LevelStr))
{
UnidentifiedLogLevels.Add(LevelStr);
Log.Warning("Failed to match log level {0} to enum!", LevelStr);
}
}
}
string Prefix = string.Empty;
if (Channel.StartsWith("log", StringComparison.OrdinalIgnoreCase))
{
Prefix = Channel.Substring(0, 3);
Channel = Channel.Substring(3);
}
Entry = new(Prefix, Channel, Level, Message);
}
else
{
// Not an Unreal Engine log line
Entry = new(string.Empty, string.Empty, UnrealLog.LogLevel.Log, Line);
}
LogEvents.Add(Entry);
}
return LogReader.GetLineIndex() - LineOffset;
}
/// <summary>
/// Return All the LogEvent instances
/// </summary>
/// <returns></returns>
public IEnumerable<UnrealLog.LogEntry> GetEvents()
{
return LogEvents;
}
/// <summary>
/// Return the LogEvent instances which channel name match
/// </summary>
/// <param name="InValues">The channel names to match</param>
/// <param name="ExactMatch">Whether to use an exact match or partial match</param>
/// <param name="UseLongName">Whether to use the long channel name to match</param>
/// <returns></returns>
private IEnumerable<UnrealLog.LogEntry> InternalGetEventsFromChannels(IEnumerable<string> InValues, bool ExactMatch = false, bool UseLongName = true)
{
if (ExactMatch)
{
return LogEvents.Where(E => InValues.Contains(UseLongName? E.LongChannelName : E.Category, StringComparer.OrdinalIgnoreCase));
}
// partial match
return LogEvents.Where(E =>
{
string Name = UseLongName? E.LongChannelName : E.Category;
foreach (string Value in InValues)
{
if (Name.IndexOf(Value, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
});
}
/// <summary>
/// Return the LogEvent instances that match the channel names
/// </summary>
/// <param name="Channels">The names of the channel to match</param>
/// <param name="ExactMatch"></param>
/// <returns></returns>
public IEnumerable<UnrealLog.LogEntry> GetEventsFromChannels(IEnumerable<string> Channels, bool ExactMatch = true)
{
return InternalGetEventsFromChannels(Channels, ExactMatch);
}
/// <summary>
/// Return the LogEvent instances that match the Editor busy channels
/// </summary>
/// <returns></returns>
public IEnumerable<UnrealLog.LogEntry> GetEventsFromEditorBusyChannels()
{
return InternalGetEventsFromChannels(UnrealLog.EditorBusyChannels, false);
}
/// <summary>
/// Return the log lines that match the channel names
/// </summary>
/// <param name="Channels">The names of the channel to match</param>
/// <param name="ExactMatch"></param>
/// <returns></returns>
public IEnumerable<string> GetLogFromChannels(IEnumerable<string> Channels, bool ExactMatch = true)
{
return InternalGetEventsFromChannels(Channels, ExactMatch).Select(E => E.ToString());
}
/// <summary>
/// Return the log lines that match the channel names ignoring the "log" prefix
/// </summary>
/// <param name="Channels"></param>
/// <returns></returns>
public IEnumerable<string> GetLogFromShortNameChannels(IEnumerable<string> Channels)
{
return InternalGetEventsFromChannels(Channels, UseLongName: false).Select(E => E.ToString());
}
/// <summary>
/// Return the log lines that match the Editor busy channels
/// </summary>
/// <returns></returns>
public IEnumerable<string> GetLogFromEditorBusyChannels()
{
return GetLogFromChannels(UnrealLog.EditorBusyChannels, false);
}
/// <summary>
/// Return the log lines that match the channel name
/// </summary>
/// <param name="Channel">The channel name to match</param>
/// <param name="ExactMatch"></param>
/// <returns></returns>
public IEnumerable<string> GetLogFromChannel(string Channel, bool ExactMatch = true)
{
return GetLogFromChannels(new string[] { Channel }, ExactMatch);
}
/// <summary>
/// Return all the aggregated log lines
/// </summary>
/// <returns></returns>
public IEnumerable<string> GetLogLines()
{
return LogEvents.Select(E => E.ToString());
}
/// <summary>
/// Return the log lines that contain a string
/// </summary>
/// <param name="Text">The text to match in the line</param>
/// <returns></returns>
public IEnumerable<string> GetLogLinesContaining(string Text)
{
return GetLogLines().Where(L => L.IndexOf(Text, StringComparison.OrdinalIgnoreCase) >= 0);
}
/// <summary>
/// Return the log lines that match the regex pattern
/// </summary>
/// <param name="Pattern">The regex pattern to match in the line</param>
/// <returns></returns>
public IEnumerable<string> GetLogLinesMatchingPattern(string Pattern)
{
Regex RegexPattern = new Regex(Pattern, RegexOptions.IgnoreCase);
return GetLogLines().Where(L => RegexPattern.IsMatch(L));
}
}
/// <summary>
/// Helper class for parsing logs
/// </summary>
public class UnrealLogParser
{
/// <summary>
/// DEPRECATED - Use GetLogReader() to read through the log stream efficiently.
/// </summary>
public string Content => _logReader.GetContent();
/// <summary>
/// Allow reading log line by line with an internal cursor
/// </summary>
public ILogStreamReader GetLogReader() => _logReader.Clone();
private ILogStreamReader _logReader { get; set; }
/// <summary>
/// All entries in the log
/// </summary>
public IEnumerable<UnrealLog.LogEntry> LogEntries => _logEntries.Value;
private Lazy<IEnumerable<UnrealLog.LogEntry>> _logEntries;
/// <summary>
/// Summary of the log
/// </summary>
private Lazy<UnrealLog> _summary;
// Track log levels we couldn't identify
protected static HashSet<string> UnidentifiedLogLevels = new HashSet<string>();
/// <summary>
/// Constructor that takes a ILogStreamReader instance
/// </summary>
/// <param name="InLogReader"></param>
public UnrealLogParser(ILogStreamReader InLogReader)
{
_logReader = InLogReader;
_logEntries = new(() => ParseEntries());
_summary = new(() => CreateSummary());
}
/// <summary>
/// Constructor that takes the content to parse
/// </summary>
/// <param name="InContent"></param>
/// <returns></returns>
public UnrealLogParser(string InContent) : this(new DynamicStringReader(() => InContent))
{ }
/// <summary>
/// Constructor that takes a UnrealLog instance
/// </summary>
/// <param name="InLog"></param>
public UnrealLogParser(UnrealLog InLog) : this(InLog.GetParser().GetLogReader())
{
_logEntries = new(() => InLog.GetParser().LogEntries);
}
protected List<UnrealLog.LogEntry> ParseEntries()
{
// Search for LogFoo: <Display|Error|etc>: Message
// Also need to handle 'Log' not always being present, and the category being empty for a level of 'Log'
Regex Pattern = new Regex(@"(?<prefix>Log)?(?<category>[A-Za-z][\w\d]+):\s(?<level>Display|Verbose|VeryVerbose|Warning|Error|Fatal)?(?::\s)?");
List<UnrealLog.LogEntry> ParsedEntries = new List<UnrealLog.LogEntry>();
_logReader.SetLineIndex(0);
foreach (string Line in _logReader.EnumerateNextLines())
{
var M = Pattern.Match(Line);
if (!M.Success) continue;
string Prefix = M.Groups["prefix"].Value;
string Category = M.Groups["category"].Value;
string Message = Line.Substring(M.Index + M.Length);
ReadOnlySpan<char> LevelSpan = M.Groups["level"].ValueSpan;
UnrealLog.LogLevel Level = UnrealLog.LogLevel.Log;
if (!LevelSpan.IsEmpty)
{
if (!Enum.TryParse(LevelSpan, out Level))
{
string LevelStr = LevelSpan.ToString();
// only show a warning once
if (!UnidentifiedLogLevels.Contains(LevelStr))
{
UnidentifiedLogLevels.Add(LevelStr);
Log.Warning("Failed to match log level {0} to enum!", LevelStr);
}
}
}
ParsedEntries.Add(new UnrealLog.LogEntry(Prefix, Category, Level, Message));
}
return ParsedEntries;
}
public static string SanitizeLogText(string InContent)
{
StringBuilder ContentBuilder = new StringBuilder();
for (int BaseIdx = 0; BaseIdx < InContent.Length;)
{
// Extract the next line
int EndIdx = InContent.IndexOf('\n', BaseIdx);
if (EndIdx == -1)
{
break;
}
// Skip over any windows CR-LF line endings
int LineEndIdx = EndIdx;
if (LineEndIdx > BaseIdx && InContent[LineEndIdx - 1] == '\r')
{
LineEndIdx--;
}
// Render any JSON log events
string Line = InContent.Substring(BaseIdx, LineEndIdx - BaseIdx);
try
{
Line = SanitizeJsonOutputLine(Line, true);
}
catch
{
int MinIdx = Math.Max(BaseIdx - 2048, 0);
int MaxIdx = Math.Min(BaseIdx + 2048, InContent.Length);
string[] Context = InContent.Substring(MinIdx, MaxIdx - MinIdx).Split('\n');
for (int idx = 1; idx < Context.Length - 1; idx++)
{
EpicGames.Core.Log.Logger.LogDebug("Context {Idx}: {Line}", idx, Context[idx].TrimEnd());
}
}
ContentBuilder.Append(Line);
ContentBuilder.Append('\n');
// Move to the next line
BaseIdx = EndIdx + 1;
}
return ContentBuilder.ToString();
}
public static string SanitizeJsonOutputLine(string Line, bool ThrowOnFailure = false)
{
if (Line.Length > 0 && Line[0] == '{')
{
try
{
byte[] Buffer = Encoding.UTF8.GetBytes(Line);
JsonLogEvent JsonEvent = JsonLogEvent.Parse(Buffer);
Line = JsonEvent.GetLegacyLogLine();
}
catch (Exception ex)
{
EpicGames.Core.Log.Logger.LogDebug(ex, "Unable to parse log line: {Line}, Exception: {Ex}", Line, ex.ToString());
if (ThrowOnFailure)
{
throw;
}
}
}
return Line;
}
public UnrealLog GetSummary() => _summary.Value;
protected UnrealLog CreateSummary() => new UnrealLog(this);
/// <summary>
/// Returns all lines from the specified content match the specified regex
/// </summary>
/// <param name="InLogReader"></param>
/// <param name="InPattern"></param>
/// <param name="InOptions"></param>
/// <returns></returns>
protected IEnumerable<Match> GetAllMatches(ILogStreamReader InLogReader, string InPattern, RegexOptions InOptions = RegexOptions.None)
{
Regex regex = new Regex(InPattern, InOptions);
InLogReader.SetLineIndex(0);
foreach (string Line in InLogReader.EnumerateNextLines())
{
Match M = regex.Match(Line);
if (!M.Success) continue;
yield return M;
}
}
/// <summary>
/// Returns all lines from the specified content match the specified regex
/// </summary>
/// <param name="InLogReader"></param>
/// <param name="InPattern"></param>
/// <param name="InOptions"></param>
/// <returns></returns>
protected IEnumerable<string> GetAllMatchingLines(ILogStreamReader InLogReader, string InPattern, RegexOptions InOptions = RegexOptions.None)
{
return GetAllMatches(InLogReader, InPattern, InOptions).Select(M => M.Value);
}
/// <summary>
/// Returns all lines from the specified content match the specified regex
/// </summary>
/// <param name="InContent"></param>
/// <param name="InPattern"></param>
/// <param name="InOptions"></param>
/// <returns></returns>
protected string[] GetAllMatchingLines(string InContent, string InPattern, RegexOptions InOptions = RegexOptions.None)
{
return GetAllMatchingLines(new DynamicStringReader(new(() => InContent)), InPattern, InOptions).ToArray();
}
/// <summary>
/// Returns all lines that match the specified regex
/// </summary>
/// <param name="InPattern"></param>
/// <param name="InOptions"></param>
/// <returns></returns>
public string[] GetAllMatchingLines(string InPattern, RegexOptions InOptions = RegexOptions.None)
{
return GetAllMatchingLines(_logReader, InPattern, InOptions).ToArray();
}
/// <summary>
/// Returns all Matches that match the specified regex
/// </summary>
/// <param name="InPattern"></param>
/// <param name="InOptions"></param>
/// <returns></returns>
public IEnumerable<Match> GetAllMatches(string InPattern, RegexOptions InOptions = RegexOptions.None)
{
return GetAllMatches(_logReader, InPattern, InOptions);
}
/// <summary>
/// Returns all lines containing the specified substring
/// </summary>
/// <param name="Substring"></param>
/// <param name="Options"></param>
/// <returns></returns>
public IEnumerable<string> GetAllContainingLines(string Substring, StringComparison Options = StringComparison.Ordinal)
{
_logReader.SetLineIndex(0);
foreach (string Line in _logReader.EnumerateNextLines())
{
if (!Line.Contains(Substring, Options)) continue;
yield return Line;
}
}
/// <summary>
/// Match regex pattern and execute callback with group values passed as argument
/// </summary>
/// <param name="InPattern"></param>
/// <param name="InFunc"></param>
/// <param name="InOptions"></param>
public void MatchAndApplyGroups(string InPattern, Action<string[]> InFunc, RegexOptions InOptions = RegexOptions.None)
{
Match M = GetAllMatches(InPattern, InOptions).FirstOrDefault();
if (M != null && M.Success)
{
InFunc(M.Groups.Values.Select(G => G.Value).ToArray());
}
}
/// <summary>
/// Returns a structure containing platform information extracted from the log
/// </summary>
/// <returns></returns>
public UnrealLog.PlatformInfo GetPlatformInfo()
{
var Info = new UnrealLog.PlatformInfo();
const string InfoRegEx = @"LogInit.+OS:\s*(.+?)\s*(\((.+)\))?,\s*CPU:\s*(.+)\s*,\s*GPU:\s*(.+)";
MatchAndApplyGroups(InfoRegEx, (Groups) =>
{
Info.OSName = Groups[1];
Info.OSVersion = Groups[3];
Info.CPUName = Groups[4];
Info.GPUName = Groups[5];
});
return Info;
}
/// <summary>
/// Returns a structure containing build information extracted from the log
/// </summary>
/// <returns></returns>
public UnrealLog.BuildInfo GetBuildInfo()
{
var Info = new UnrealLog.BuildInfo();
// pull from Branch Name: <name>
Match M = GetAllMatches(@"LogInit.+Name:\s*(.*)", RegexOptions.IgnoreCase).FirstOrDefault();
if (M != null && M.Success)
{
Info.BranchName = M.Groups[1].Value;
Info.BranchName = Info.BranchName.Replace("+", "/");
}
M = GetAllMatches(@"LogInit.+CL-(\d+)", RegexOptions.IgnoreCase).FirstOrDefault();
if (M != null && M.Success)
{
Info.Changelist = Convert.ToInt32(M.Groups[1].Value);
}
M = GetAllMatches(@"LogInit.+Build:\s*(\+.*)", RegexOptions.IgnoreCase).FirstOrDefault();
if (M != null && M.Success)
{
Info.BuildVersion = M.Groups[1].Value;
}
return Info;
}
/// <summary>
/// Returns all entries from the log that have the specified level
/// </summary>
/// <param name="InLevel"></param>
/// <returns></returns>
public IEnumerable<UnrealLog.LogEntry> GetEntriesOfLevel(UnrealLog.LogLevel InLevel)
{
IEnumerable<UnrealLog.LogEntry> Entries = LogEntries.Where(E => E.Level == InLevel);
return Entries;
}
/// <summary>
/// Returns all warnings from the log
/// </summary>
/// <param name="InCategories"></param>
/// <param name="ExactMatch"></param>
/// <returns></returns>
public IEnumerable<UnrealLog.LogEntry> GetEntriesOfCategories(IEnumerable<string> InCategories, bool ExactMatch = false)
{
IEnumerable<UnrealLog.LogEntry> Entries;
if (ExactMatch)
{
Entries = LogEntries.Where(E => InCategories.Contains(E.Category, StringComparer.OrdinalIgnoreCase));
}
else
{
// check if each channel is a substring of each log entry. E.g. "Shader" should return entries
// with both ShaderCompiler and ShaderManager
Entries = LogEntries.Where(E =>
{
string LogEntryCategory = E.Category;
foreach (string Cat in InCategories)
{
if (LogEntryCategory.IndexOf(Cat, StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
});
}
return Entries;
}
/// <summary>
/// Return all entries for the specified channel. E.g. "OrionGame" will
/// return all entries starting with LogOrionGame
/// </summary>
/// <param name="Channels"></param>
/// <param name="ExactMatch"></param>
/// <returns></returns>
public IEnumerable<string> GetLogChannels(IEnumerable<string> Channels, bool ExactMatch = true)
{
return GetEntriesOfCategories(Channels, ExactMatch).Select(E => E.ToString());
}
/// <summary>
/// Returns channels that signify the editor doing stuff
/// </summary>
/// <returns></returns>
public IEnumerable<string> GetEditorBusyChannels()
{
return GetLogChannels(UnrealLog.EditorBusyChannels, false);
}
/// <summary>
/// Return all entries for the specified channel. E.g. "OrionGame" will
/// return all entries starting with LogOrionGame
/// </summary>
/// <param name="Channel"></param>
/// <param name="ExactMatch"></param>
/// <returns></returns>
public IEnumerable<string> GetLogChannel(string Channel, bool ExactMatch = true)
{
return GetLogChannels(new string[] { Channel }, ExactMatch);
}
/// <summary>
/// Returns all warnings from the log
/// </summary>
/// <param name="InChannel">Optional channel to restrict search to</param>
/// <returns></returns>
public IEnumerable<string> GetWarnings(string InChannel = null)
{
IEnumerable<UnrealLog.LogEntry> Entries = LogEntries.Where(E => E.Level == UnrealLog.LogLevel.Warning);
if (InChannel != null)
{
Entries = Entries.Where(E => E.Category.Equals(InChannel, StringComparison.OrdinalIgnoreCase));
}
return Entries.Select(E => E.ToString());
}
/// <summary>
/// Returns all errors from the log
/// </summary>
/// <param name="InChannel">Optional channel to restrict search to</param>
/// <returns></returns>
public IEnumerable<string> GetErrors(string InChannel = null)
{
IEnumerable<UnrealLog.LogEntry> Entries = LogEntries.Where(E => E.Level == UnrealLog.LogLevel.Error);
if (InChannel != null)
{
Entries = Entries.Where(E => E.Category.Equals(InChannel, StringComparison.OrdinalIgnoreCase));
}
return Entries.Select(E => E.ToString());
}
/// <summary>
/// Returns all ensures from the log
/// </summary>
/// <returns></returns>
public IEnumerable<UnrealLog.CallstackMessage> GetEnsures()
{
IEnumerable<UnrealLog.CallstackMessage> Ensures = ParseTracedErrors(new[] { @"Log.+:\s{0,1}Error:\s{0,1}(Ensure condition failed:.+)" }, 10);
foreach (UnrealLog.CallstackMessage Error in Ensures)
{
Error.IsEnsure = true;
}
return Ensures;
}
/// <summary>
/// If the log contains a fatal error return that information
/// </summary>
/// <returns></returns>
public UnrealLog.CallstackMessage GetFatalError()
{
string[] ErrorMsgMatches = new string[] { @"(Fatal Error:.+)", @"Critical error: =+\s+(?:[\S\s]+?\s*Error: +)?(.+)", @"(Assertion Failed:.+)", @"(Unhandled Exception:.+)", @"(LowLevelFatalError.+)", @"(Postmortem Cause:.*)" };
var Traces = ParseTracedErrors(ErrorMsgMatches, 5).Concat(GetASanErrors());
// If we have a post-mortem error, return that one (on some devices the post-mortem info is way more informative).
var PostMortemTraces = Traces.Where(T => T.Message.IndexOf("Postmortem Cause:", StringComparison.OrdinalIgnoreCase) > -1);
if (PostMortemTraces.Any())
{
Traces = PostMortemTraces;
}
// Keep the one with the most information.
return Traces.Count() > 0 ? Traces.OrderBy(T => T.Callstack.Length).Last() : null;
}
/// <summary>
/// Parse the log for Address Sanitizer error.
/// </summary>
/// <returns></returns>
public IEnumerable<UnrealLog.CallstackMessage> GetASanErrors()
{
// Match:
// ==5077==ERROR: AddressSanitizer: alloc - dealloc - mismatch(operator new vs free) on 0x602014ab4790
// Then for gathering the callstack, match
// ==5077==ABORTING
// remove anything inside the callstack starting with [2022.12.02-15.22.40:688][618]
List<UnrealLog.CallstackMessage> ASanReports = new List<UnrealLog.CallstackMessage>();
Regex InitPattern = SanitizerEventMatcher.ReportLevelPattern;
Regex EndPattern = SanitizerEventMatcher.ReportEndPattern;
Regex LineStartsWithTimeStamp = new Regex(@"^[\s\t]*\[[0-9.:-]+\]\[[\s0-9]+\]");
UnrealLog.CallstackMessage NewTrace = null;
List<string> Backtrace = null;
Action AddTraceToList = () =>
{
if (Backtrace.Count == 0)
{
Backtrace.Add("Unable to parse callstack from log");
}
NewTrace.Callstack = Backtrace.ToArray();
ASanReports.Add(NewTrace);
NewTrace = null;
Backtrace = null;
};
_logReader.SetLineIndex(0);
foreach (string Line in _logReader.EnumerateNextLines())
{
if (NewTrace == null)
{
Match TraceInitMatch = InitPattern.Match(Line);
if (TraceInitMatch.Success && SanitizerEventMatcher.ConvertReportLevel(TraceInitMatch.Groups["ReportLevel"].Value) == Logging.LogLevel.Error)
{
NewTrace = new UnrealLog.CallstackMessage();
NewTrace.IsSanReport = true;
NewTrace.Position = _logReader.GetLineIndex() - 1;
NewTrace.Message = $"{TraceInitMatch.Groups["SanitizerName"].Value}Sanitizer: {TraceInitMatch.Groups["Summary"].Value}";
Backtrace = new List<string>();
}
continue;
}
if (EndPattern.IsMatch(Line))
{
AddTraceToList();
}
else
{
// Prune the line with UE log timestamp
if (!LineStartsWithTimeStamp.IsMatch(Line))
{
Backtrace.Add(Line);
}
}
}
if (NewTrace != null)
{
// Happen if end of log is reached before the EndPattern is found
AddTraceToList();
}
return ASanReports;
}
/// <summary>
/// Returns true if the log contains a test complete marker
/// </summary>
/// <returns></returns>
public bool HasTestCompleteMarker() => GetAllMatchingLines(@"\*\*\* TEST COMPLETE.+").Any();
/// <summary>
/// Returns true if the log contains a request to exit that was not due to an error
/// </summary>
/// <returns></returns>
public bool HasRequestExit()
{
return GetSummary().RequestedExit;
}
/// <summary>
/// Returns a block of lines that start and end with the specified regex patterns
/// </summary>
/// <param name="StartPattern">Regex to match the first line</param>
/// <param name="EndPattern">Regex to match the final line</param>
/// <param name="PatternOptions">Optional RegexOptions applied to both patterns. IgnoreCase by default.</param>
/// <returns>Array of strings for each found block of lines. Lines within each string are delimited by newline character.</returns>
public string[] GetGroupsOfLinesBetween(string StartPattern, string EndPattern, RegexOptions PatternOptions = RegexOptions.IgnoreCase)
{
Regex StartRegex = new Regex(StartPattern, PatternOptions);
Regex EndRegex = new Regex(EndPattern, PatternOptions);
List<string> Blocks = new List<string>();
List<string> Block = null;
_logReader.SetLineIndex(0);
foreach (string Line in _logReader.EnumerateNextLines())
{
if (Block == null)
{
if (!StartRegex.IsMatch(Line)) continue;
Block = new(){ Line };
}
else
{
Block.Add(Line);
if (EndRegex.IsMatch(Line))
{
Blocks.Add(string.Join('\n', Block));
Block = null;
}
}
}
return Blocks.ToArray();
}
/// <summary>
/// Returns a block of lines that start with the specified regex
/// </summary>
/// <param name="Pattern">Regex to match the first line</param>
/// <param name="LineCount">Number of lines in the returned block</param>
/// <param name="PatternOptions"></param>
/// <returns>Array of strings for each found block of lines. Lines within each string are delimited by newline character.</returns>
public string[] GetGroupsOfLinesStartingWith(string Pattern, int LineCount, RegexOptions PatternOptions = RegexOptions.IgnoreCase)
{
Regex RegexPattern = new Regex(Pattern, PatternOptions);
List<string> Blocks = new List<string>();
List<string> Block = null;
_logReader.SetLineIndex(0);
foreach (string Line in _logReader.EnumerateNextLines())
{
if (Block == null)
{
if (!RegexPattern.IsMatch(Line)) continue;
Block = new() { Line };
}
else
{
Block.Add(Line);
}
if (Block.Count >= LineCount)
{
Blocks.Add(string.Join('\n', Block));
Block = null;
}
}
return Blocks.ToArray();
}
/// <summary>
/// Finds all callstack-based errors with the specified pattern
/// </summary>
/// <param name="Patterns"></param>
/// <param name="Limit">Limit the number of errors to parse with trace per pattern. Zero means no limit.</param>
/// <returns></returns>
protected IEnumerable<UnrealLog.CallstackMessage> ParseTracedErrors(string[] Patterns, int Limit = 0)
{
List<UnrealLog.CallstackMessage> Traces = new List<UnrealLog.CallstackMessage>();
Dictionary<string, (Regex Pattern, int Remaining)> RegexPatterns = new();
foreach(string Pattern in Patterns)
{
RegexPatterns.Add(Pattern, (new Regex(Pattern, RegexOptions.IgnoreCase), Limit));
};
_logReader.SetLineIndex(0);
foreach (string Line in _logReader.EnumerateNextLines())
{
if (Limit > 0 && RegexPatterns.Count == 0) break;
if (string.IsNullOrEmpty(Line)) continue;
// Try and find an error message
string SelectedPattern = null;
foreach (var Entry in RegexPatterns)
{
Match TraceMatch = Entry.Value.Pattern.Match(Line);
if (TraceMatch.Success)
{
UnrealLog.CallstackMessage NewTrace = new UnrealLog.CallstackMessage();
NewTrace.Position = _logReader.GetLineIndex() - 1;
NewTrace.Message = TraceMatch.Groups[1].Value;
SelectedPattern = Entry.Key;
Traces.Add(NewTrace);
break;
}
}
// Track pattern match limit
if (Limit > 0 && !string.IsNullOrEmpty(SelectedPattern))
{
var SelectedItem = RegexPatterns[SelectedPattern];
int Remaining = SelectedItem.Remaining - 1;
if (Remaining <= 0)
{
// Limit reached, we remove the pattern from collection
RegexPatterns.Remove(SelectedPattern);
}
else
{
// Update count
RegexPatterns[SelectedPattern] = (SelectedItem.Pattern, Remaining);
}
}
}
//
// Handing callstacks-
//
// Unreal now uses a canonical format for printing callstacks during errors which is
//
//0xaddress module!func [file]
//
// E.g. 0x045C8D01 OrionClient.self!UEngine::PerformError() [D:\Epic\Orion\Engine\Source\Runtime\Engine\Private\UnrealEngine.cpp:6481]
//
// Module may be omitted, everything else should be present, or substituted with a string that conforms to the expected type
//
// E.g 0x00000000 UnknownFunction []
//
// A callstack as part of an ensure, check, or exception will look something like this -
//
//
//[2017.08.21-03.28.40:667][313]LogWindows:Error: Assertion failed: false [File:D:\Epic\Orion\Release-Next\Engine\Plugins\NotForLicensees\Gauntlet\Source\Gauntlet\Private\GauntletTestControllerErrorTest.cpp] [Line: 29]
//[2017.08.21-03.28.40:667][313]LogWindows:Error: Asserting as requested
//[2017.08.21-03.28.40:667][313]LogWindows:Error:
//[2017.08.21-03.28.40:667][313]LogWindows:Error:
//[2017.08.21-03.28.40:667][313]LogWindows:Error: [Callstack] 0x00000000FDC2A06D KERNELBASE.dll!UnknownFunction []
//[2017.08.21-03.28.40:667][313]LogWindows:Error: [Callstack] 0x00000000418C0119 OrionClient.exe!FOutputDeviceWindowsError::Serialize() [d:\epic\orion\release-next\engine\source\runtime\core\private\windows\windowsplatformoutputdevices.cpp:120]
//[2017.08.21-03.28.40:667][313]LogWindows:Error: [Callstack] 0x00000000416AC12B OrionClient.exe!FOutputDevice::Logf__VA() [d:\epic\orion\release-next\engine\source\runtime\core\private\misc\outputdevice.cpp:70]
//[2017.08.21-03.28.40:667][313]LogWindows:Error: [Callstack] 0x00000000418BD124 OrionClient.exe!FDebug::AssertFailed() [d:\epic\orion\release-next\engine\source\runtime\core\private\misc\assertionmacros.cpp:373]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x000000004604A879 OrionClient.exe!UGauntletTestControllerErrorTest::OnTick() [d:\epic\orion\release-next\engine\plugins\notforlicensees\gauntlet\source\gauntlet\private\gauntlettestcontrollererrortest.cpp:29]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x0000000046049166 OrionClient.exe!FGauntletModuleImpl::InnerTick() [d:\epic\orion\release-next\engine\plugins\notforlicensees\gauntlet\source\gauntlet\private\gauntletmodule.cpp:315]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x0000000046048472 OrionClient.exe!TBaseFunctorDelegateInstance<bool __cdecl(float),<lambda_b2e6da8e95d7ed933c391f0ec034aa11> >::Execute() [d:\epic\orion\release-next\engine\source\runtime\core\public\delegates\delegateinstancesimpl.h:1132]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x00000000415101BE OrionClient.exe!FTicker::Tick() [d:\epic\orion\release-next\engine\source\runtime\core\private\containers\ticker.cpp:82]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x00000000402887DD OrionClient.exe!FEngineLoop::Tick() [d:\epic\orion\release-next\engine\source\runtime\launch\private\launchengineloop.cpp:3295]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x00000000402961FC OrionClient.exe!GuardedMain() [d:\epic\orion\release-next\engine\source\runtime\launch\private\launch.cpp:166]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x000000004029625A OrionClient.exe!GuardedMainWrapper() [d:\epic\orion\release-next\engine\source\runtime\launch\private\windows\launchwindows.cpp:134]
//[2017.08.21-03.28.40:668][313]LogWindows:Error: [Callstack] 0x00000000402A2D68 OrionClient.exe!WinMain() [d:\epic\orion\release-next\engine\source\runtime\launch\private\windows\launchwindows.cpp:210]
//[2017.08.21-03.28.40:669][313]LogWindows:Error: [Callstack] 0x0000000046EEC0CB OrionClient.exe!__scrt_common_main_seh() [f:\dd\vctools\crt\vcstartup\src\startup\exe_common.inl:253]
//[2017.08.21-03.28.40:669][313]LogWindows:Error: [Callstack] 0x0000000077A759CD kernel32.dll!UnknownFunction []
//[2017.08.21-03.28.40:669][313]LogWindows:Error: [Callstack] 0x0000000077CAA561 ntdll.dll!UnknownFunction []
//[2017.08.21-03.28.40:669][313]LogWindows:Error: [Callstack] 0x0000000077CAA561 ntdll.dll!UnknownFunction []
//
// So the code below starts at the point of the error message, and searches subsequent lines for things that look like a callstack. If we go too many lines without
// finding one then we break. Note that it's possible that log messages from another thread may be intermixed, so we can't just break on a change of verbosity or
// channel
//
// Must contain 0x00123456 module name [filename]
// The module name is optional, must start with whitespace, and continues until next white space follow by [
// filename is optional, must be in [filename]
// module address is optional, must be in quotes() after address
// Note - Unreal callstacks are always meant to omit all three with placeholders for missing values, but
// we'll assume that may not happen...
Regex CallstackMatch = new Regex(@"(0[xX][0-9A-f]{8,16})(?:\s+\(0[xX][0-9A-f]{8,16}\))?\s+(.+?)\s+\[(.*?)\][^\w]*$");
Regex ExtraErrorLine = new Regex(@".+:\s*Error:\s*");
Regex TimestampMatch = new Regex(@"\[.+\]\[.+\]Log\w+\:");
foreach (UnrealLog.CallstackMessage NewTrace in Traces)
{
List<string> Backtrace = new List<string>();
int LinesWithoutBacktrace = 0;
// Move to Trace next line index
_logReader.SetLineIndex(NewTrace.Position + 1);
foreach (string Line in _logReader.EnumerateNextLines())
{
if (string.IsNullOrEmpty(Line)) continue;
Match CSMatch = CallstackMatch.Match(Line);
if (CSMatch.Success)
{
// Callstack pattern found
string Address = CSMatch.Groups[1].Value;
string Func = CSMatch.Groups[2].Value;
string File = CSMatch.Groups[3].Value;
if (string.IsNullOrEmpty(File))
{
File = "Unknown File";
}
// Remove any exe
const string StripFrom = ".exe!";
if (Func.IndexOf(StripFrom) > 0)
{
Func = Func.Substring(Func.IndexOf(StripFrom) + StripFrom.Length);
}
Backtrace.Add($"{Address} {Func} [{File}]");
LinesWithoutBacktrace = 0;
}
else
{
if (Backtrace.Count == 0)
{
// Add additional summary error lines before backtrace lines are found
Match NLMatch = TimestampMatch.Match(Line);
if (!NLMatch.Success)
{ //New log line
NewTrace.Message += "\n" + Line;
}
else
{
Match MsgMatch = ExtraErrorLine.Match(Line);
if (MsgMatch.Success)
{ // Line with error tag
string MsgString = Line.Substring(MsgMatch.Index + MsgMatch.Length).Trim();
if (string.IsNullOrEmpty(MsgString)) continue;
NewTrace.Message += "\n" + MsgString;
}
}
}
LinesWithoutBacktrace++;
}
if (LinesWithoutBacktrace >= 10)
{
// No more callstack line found, stop parsing
break;
}
}
NewTrace.Callstack = Backtrace.Count > 0? Backtrace.Distinct().ToArray() : new[] { "Unable to parse callstack from log" };
}
UnrealLog.CallstackMessage PreviousTrace = null;
return Traces.Where(Trace =>
{
// Because platforms sometimes dump asserts to the log and low-level logging, we need to prune out redundancies.
// Basic approach: find errors with the same assert message and keep the one with the longest callstack.
if (PreviousTrace != null && Trace.Message.Equals(PreviousTrace.Message, StringComparison.OrdinalIgnoreCase))
{
if (PreviousTrace.Callstack.Length < Trace.Callstack.Length)
{
PreviousTrace.Callstack = Trace.Callstack;
}
return false;
}
PreviousTrace = Trace;
return true;
}).ToList(); // Force execution here with ToList() to have the duplicates pruned only once.
}
/// <summary>
/// Attempts to find an exit code for a test
/// </summary>
/// <param name="ExitCode"></param>
/// <returns></returns>
public bool GetTestExitCode(out int ExitCode)
{
Match M = GetAllMatches(@"\*\s+TEST COMPLETE. EXIT CODE:\s*(-?\d?)\s+\*").FirstOrDefault();
if (M != null && M.Success && M.Groups.Count > 1)
{
ExitCode = Convert.ToInt32(M.Groups[1].Value);
return true;
}
M = GetAllMatches(@"RequestExitWithStatus\(\d+,\s*(\d+).*\)").FirstOrDefault();
if (M != null && M.Success && M.Groups.Count > 1)
{
ExitCode = Convert.ToInt32(M.Groups[1].Value);
return true;
}
if (GetAllContainingLines("EnvironmentalPerfTest summary").Any())
{
Log.Warning("Found - 'EnvironmentalPerfTest summary', using temp workaround and assuming success (!)");
ExitCode = 0;
return true;
}
ExitCode = -1;
return false;
}
}
}