// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace EpicGames.Core { /// /// Confidence of a matched log event being the correct derivation /// public enum LogEventPriority { /// /// Unspecified priority /// None, /// /// Lowest confidence match /// Lowest, /// /// Low confidence match /// Low, /// /// Below normal confidence match /// BelowNormal, /// /// Normal confidence match /// Normal, /// /// Above normal confidence match /// AboveNormal, /// /// High confidence match /// High, /// /// Highest confidence match /// Highest, } /// /// Information about a matched event /// public class LogEventMatch { /// /// Confidence of the match /// public LogEventPriority Priority { get; } /// /// Matched events /// public List Events { get; } /// /// Constructor /// public LogEventMatch(LogEventPriority priority, LogEvent logEvent) { Priority = priority; Events = [logEvent]; } /// /// Constructor /// public LogEventMatch(LogEventPriority priority, IEnumerable events) { Priority = priority; Events = events.ToList(); } } /// /// Interface for a class which matches error strings /// public interface ILogEventMatcher { /// /// Attempt to match events from the given input buffer /// /// The input buffer /// Information about the error that was matched, or null if an error was not matched LogEventMatch? Match(ILogCursor cursor); } /// /// Turns raw text output into structured logging events /// public class LogEventParser : IDisposable { /// /// List of event matchers for this parser /// public List Matchers { get; } = []; /// /// List of patterns to ignore /// public List IgnorePatterns { get; } = []; /// /// Buffer of input lines /// readonly LogBuffer _buffer; /// /// Buffer for holding partial line data /// readonly ByteArrayBuilder _partialLine = new ByteArrayBuilder(); /// /// Whether matching is currently enabled /// int _matchingEnabled; /// /// The inner logger /// ILogger _logger; /// /// Log events sinks in addition to /// readonly List _logEventSinks = []; /// /// Timer for the parser being active /// readonly Stopwatch _timer = Stopwatch.StartNew(); /// /// Amount of time that the log parser has been processing events /// readonly Stopwatch _activeTimer = new Stopwatch(); /// /// Number of lines parsed in the last interval /// int _linesParsed = 0; /// /// Public accessor for the logger /// public ILogger Logger { get => _logger; set => _logger = value; } /// /// Constructor /// /// The logger to receive parsed output messages /// Additional sinks to receive log events public LogEventParser(ILogger logger, List? logEventSinks = null) { _logger = logger; _buffer = new LogBuffer(50); if (logEventSinks != null) { _logEventSinks.AddRange(logEventSinks); } } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Standard Dispose pattern method /// /// protected virtual void Dispose(bool disposing) { if (disposing) { Flush(); } } /// /// Enumerate all the types that implement in the given assembly, and create instances of them /// /// The assembly to enumerate matchers from public void AddMatchersFromAssembly(Assembly assembly) { foreach (Type type in assembly.GetTypes()) { if (type.IsClass && typeof(ILogEventMatcher).IsAssignableFrom(type)) { ILogEventMatcher matcher = (ILogEventMatcher)Activator.CreateInstance(type)!; Matchers.Add(matcher); } } } /// /// Read ignore patterns from the given root directory /// /// /// public async Task ReadIgnorePatternsAsync(DirectoryReference rootDir) { Stopwatch timer = Stopwatch.StartNew(); List baseDirs = [rootDir]; AddRestrictedDirs(baseDirs, "Restricted"); AddRestrictedDirs(baseDirs, "Platforms"); List<(FileReference, Task)> tasks = []; foreach (DirectoryReference baseDir in baseDirs) { FileReference ignorePatternFile = FileReference.Combine(baseDir, "Build", "Horde", "IgnorePatterns.txt"); if (FileReference.Exists(ignorePatternFile)) { _logger.LogDebug("Reading ignore patterns from {File}...", ignorePatternFile); tasks.Add((ignorePatternFile, ReadIgnorePatternsAsync(ignorePatternFile))); } } foreach ((FileReference file, Task task) in tasks) { try { await task; } catch (Exception ex) { _logger.LogError(ex, "Exception reading patterns from {File}: {Message}", file, ex.Message); } } _logger.LogDebug("Took {TimeMs}ms to read ignore patterns", timer.ElapsedMilliseconds); } /// /// Read ignore patterns from a single file /// /// /// public async Task ReadIgnorePatternsAsync(FileReference ignorePatternFile) { string[] lines = await FileReference.ReadAllLinesAsync(ignorePatternFile); List patterns = []; foreach (string line in lines) { string trimLine = line.Trim(); if (trimLine.Length > 0 && trimLine[0] != '#') { patterns.Add(new Regex(trimLine)); } } lock (IgnorePatterns) { IgnorePatterns.AddRange(patterns); } } static void AddRestrictedDirs(List directories, string subFolder) { int numDirs = directories.Count; for (int idx = 0; idx < numDirs; idx++) { DirectoryReference subDir = DirectoryReference.Combine(directories[idx], subFolder); if (DirectoryReference.Exists(subDir)) { directories.AddRange(DirectoryReference.EnumerateDirectories(subDir)); } } } /// /// Writes a line to the event filter /// /// The line to output public void WriteLine(string line) { bool invalidJson = false; if (line.Length > 0 && line[0] == '{') { int length = line.Length; while(length > 0 && Char.IsWhiteSpace(line[length - 1])) { length--; } byte[] data = Encoding.UTF8.GetBytes(line, 0, length); try { JsonLogEvent jsonEvent; if (JsonLogEvent.TryParse(data, out jsonEvent)) { ProcessData(true); _logger.Log(jsonEvent.Level, jsonEvent.EventId, jsonEvent, null, JsonLogEvent.Format); return; } invalidJson = true; } catch (Exception ex) { _logger.LogError(ex, "Exception while parsing log event: {Message}", ex.Message); } } _buffer.AddLine(StringUtils.ParseEscapeCodes(line)); ProcessData(false, disableMatching: invalidJson); } /// /// Writes data to the log parser /// /// Data to write public void WriteData(ReadOnlyMemory data) { int baseIdx = 0; int scanIdx = 0; ReadOnlySpan span = data.Span; // Handle a partially existing line if (_partialLine.Length > 0) { for (; scanIdx < span.Length; scanIdx++) { if (span[scanIdx] == '\n') { _partialLine.WriteFixedLengthBytes(span.Slice(baseIdx, scanIdx - baseIdx)); FlushPartialLine(); baseIdx = ++scanIdx; break; } } } // Handle any complete lines for (; scanIdx < span.Length; scanIdx++) { if(span[scanIdx] == '\n') { AddLine(data.Slice(baseIdx, scanIdx - baseIdx)); baseIdx = scanIdx + 1; } } // Add the rest of the text to the partial line buffer _partialLine.WriteFixedLengthBytes(span.Slice(baseIdx)); // Process the new data ProcessData(false); } /// /// Flushes the current contents of the parser /// public void Flush() { // If there's a partially written line, write that out first if (_partialLine.Length > 0) { FlushPartialLine(); } // Process any remaining data ProcessData(true); } /// /// Adds a raw utf-8 string to the buffer /// /// The string data private void AddLine(ReadOnlyMemory data) { if (data.Length > 0 && data.Span[data.Length - 1] == '\r') { data = data.Slice(0, data.Length - 1); } if (data.Length > 0 && data.Span[0] == '{') { JsonLogEvent jsonEvent; if (JsonLogEvent.TryParse(data, out jsonEvent)) { ProcessData(true); _logger.LogJsonLogEvent(jsonEvent); return; } } _buffer.AddLine(StringUtils.ParseEscapeCodes(Encoding.UTF8.GetString(data.Span))); } /// /// Writes the current partial line data, with the given data appended to it, then clear the buffer /// private void FlushPartialLine() { AddLine(_partialLine.ToByteArray()); _partialLine.Clear(); } /// /// Process any data in the buffer /// /// Whether we've reached the end of the stream /// Whether to skip matching void ProcessData(bool bFlush, bool disableMatching = false) { _activeTimer.Start(); int startLineCount = _buffer.Length; while (_buffer.Length > 0) { // Try to match an event List? events = null; if (Regex.IsMatch(_buffer[0]!, "<-- Suspend Log Parsing -->", RegexOptions.IgnoreCase)) { _matchingEnabled--; } else if (Regex.IsMatch(_buffer[0]!, "<-- Resume Log Parsing -->", RegexOptions.IgnoreCase)) { _matchingEnabled++; } else if (_matchingEnabled >= 0 && disableMatching == false) { events = MatchEvent(); } // Bail out if we need more data if (_buffer.Length < 1024 && !bFlush && _buffer.NeedMoreData) { break; } // If we did match something, check if it's not negated by an ignore pattern. We typically have relatively few errors and many more ignore patterns than matchers, so it's quicker // to check them in response to an identified error than to treat them as matchers of their own. if (events != null) { foreach (Regex ignorePattern in IgnorePatterns) { if (ignorePattern.IsMatch(_buffer[0]!)) { events = null; break; } } } // Report the error to the listeners if (events != null) { WriteEvents(events); _buffer.Advance(events.Count); } else { _logger.Log(LogLevel.Information, KnownLogEvents.None, _buffer[0]!, null, (state, exception) => state); _buffer.MoveNext(); } } _linesParsed += startLineCount - _buffer.Length; _activeTimer.Stop(); const double UpdateIntervalSeconds = 30.0; double elapsedSeconds = _timer.Elapsed.TotalSeconds; if (elapsedSeconds > UpdateIntervalSeconds) { const double WarnPct = 0.5; double activeSeconds = _activeTimer.Elapsed.TotalSeconds; double activePct = activeSeconds / elapsedSeconds; if (activePct > WarnPct) { _logger.LogInformation(KnownLogEvents.Systemic_LogParserBottleneck, "EpicGames.Core.LogEventParser is taking a significant amount of CPU time: {Active:n1}s/{Total:n1}s ({Pct:n1}%). Processed {NumLines} lines in last {Interval} seconds ({NumLinesInBuffer} in buffer).", activeSeconds, elapsedSeconds, activePct * 100.0, _linesParsed, UpdateIntervalSeconds, _buffer.Length); } _activeTimer.Reset(); _timer.Restart(); _linesParsed = 0; } } /// /// Try to match an event from the current buffer /// /// The matched event private List? MatchEvent() { LogEventMatch? currentMatch = null; foreach (ILogEventMatcher matcher in Matchers) { LogEventMatch? match = null; try { match = matcher.Match(_buffer); } catch (Exception ex) { _logger.LogWarning(KnownLogEvents.Systemic_LogEventMatcher, ex, "Exception while parsing log events with {Type}. Buffer size {Length}.", matcher.GetType().Name, _buffer.Length); } if(match != null) { if (currentMatch == null || match.Priority > currentMatch.Priority) { currentMatch = match; } } } return currentMatch?.Events; } /// /// Writes an event to the log /// /// The event to write protected virtual void WriteEvents(List logEvents) { foreach (LogEvent logEvent in logEvents) { _logger.Log(logEvent.Level, logEvent.Id, logEvent, null, (state, exception) => state.ToString()); foreach (ILogEventSink logEventSink in _logEventSinks) { logEventSink.ProcessEvent(logEvent); } } } } }