// Copyright Epic Games, Inc. All Rights Reserved. using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using EpicGames.Core; using Microsoft.Extensions.Logging; namespace EpicGames.Horde.Issues.Handlers { /// /// Instance of a particular Gauntlet error /// [IssueHandler] public class GauntletIssueHandler : IssueHandler { /// /// Prefix for framework keys /// const string FrameworkPrefix = "test framework"; /// /// Prefix for test keys /// const string TestPrefix = "test"; /// /// Prefix for device keys /// const string DevicePrefix = "device"; /// /// Prefix for build drop keys /// const string BuildDropPrefix = "build drop"; /// /// Prefix for fatal failure keys /// const string FatalPrefix = "fatal"; /// /// Callstack log type property /// const string CallstackLogType = "Callstack"; /// /// Summary log type property /// const string SummaryLogType = "Summary"; /// /// Max Number of lines to consider to hash /// const int MaxLines = 6; /// /// Max Message Length to hash /// const int MaxMessageLength = 2000; /// /// Whether or not a severe event was reported /// bool _wasSevereEventReported = false; /// /// Whether or not a test event was reported /// bool _wasTestEventReported = false; /// /// Whether or not at least one error was reported /// bool _wasErrorEventReported = false; readonly IssueHandlerContext _context; readonly List _issues = new List(); /// /// Known Gauntlet events /// static readonly Dictionary s_knownGauntletEvents = new Dictionary { { KnownLogEvents.Gauntlet, FrameworkPrefix}, { KnownLogEvents.Gauntlet_TestEvent, TestPrefix}, { KnownLogEvents.Gauntlet_DeviceEvent, DevicePrefix}, { KnownLogEvents.Gauntlet_UnrealEngineTestEvent, TestPrefix}, { KnownLogEvents.Gauntlet_BuildDropEvent, BuildDropPrefix}, { KnownLogEvents.Gauntlet_FatalEvent, FatalPrefix} }; /// /// Known Gauntlet events associated with the highest severity /// static readonly HashSet s_knownSevereGauntletEvents = new HashSet { KnownLogEvents.Gauntlet_BuildDropEvent, KnownLogEvents.Gauntlet_FatalEvent }; /// /// Known Gauntlet events associated with test context /// static readonly HashSet s_knownTestGauntletEvents = new HashSet { KnownLogEvents.Gauntlet_TestEvent, KnownLogEvents.Gauntlet_UnrealEngineTestEvent }; /// /// Gauntlet events to skip issue generation /// static readonly HashSet s_skipGauntletEvents = new HashSet { KnownLogEvents.Gauntlet_DeviceEvent }; /// /// Known summaries that are not specific enough /// static readonly string[] s_unspecificSummaries = [ "Unhandled Exception: EXCEPTION_ACCESS_VIOLATION reading address" ]; /// public override int Priority => 10; /// /// Constructor /// public GauntletIssueHandler(IssueHandlerContext context) => _context = context; /// /// Determines if the given event id matches /// /// The event id to compare /// True if the given event id matches public static bool IsMatchingEventId(EventId eventId) { return s_knownGauntletEvents.ContainsKey(eventId); } /// /// Return the prefix string associate with the event id /// /// The event id to get the information from /// The corresponding prefix as a string public static string GetEventPrefix(EventId eventId) { return s_knownGauntletEvents[eventId]; } /// /// Produce a hash from error message /// /// The issue event /// Receives a set of the keys /// Receives a set of metadata /// Set true if a callstack property was found private void GetHash(IssueEvent issueEvent, HashSet keys, HashSet metadata, out bool hasCallstack) { hasCallstack = false; if (TryGetHash(issueEvent, out Md5Hash hash)) { string key = $"hash:{hash}"; hasCallstack = EventHasCallstackProperty(issueEvent); if (!hasCallstack) { // add job step salt if no Callstack property was found key += $":{_context.StreamId}:{_context.NodeName}"; } keys.Add(key, IssueKeyType.None); issueEvent.AuditLogger?.LogDebug("Fingerprint key: '{Key}' generated from event: '{Event}'", key, issueEvent.Render()); metadata.Add("Hash", hash.ToString()); } else { // Not enough information, make it an issue associated with only the job step keys.Add($"{_context.StreamId}:{_context.NodeName}", IssueKeyType.None); issueEvent.AuditLogger?.LogDebug("Fingerprint key: '{Key}' generated from event: '{Event}'", $"{_context.StreamId}:{_context.NodeName}", issueEvent.Render()); } metadata.Add("Node", _context.NodeName); } private static bool TryGetHash(IssueEvent issueEvent, out Md5Hash hash) { // Use only the summary if one is found instead of the full callstack string? summary = GetSummaryProperty(issueEvent); // Discard summaries that are too broad if (summary != null && s_unspecificSummaries.Any(k => summary.Contains(k, StringComparison.InvariantCultureIgnoreCase))) { summary = null; } string sanitized = summary ?? issueEvent.Render(); sanitized = String.Join("\n", sanitized.Split("\n", MaxLines + 1).Take(MaxLines)); // Limit the number of lines to consider sanitized = sanitized.Length > MaxMessageLength ? sanitized.Substring(0, MaxMessageLength) : sanitized; sanitized = sanitized.Trim().ToUpperInvariant(); sanitized = Regex.Replace(sanitized, @"(?:(? 30) { hash = Md5Hash.Compute(Encoding.UTF8.GetBytes(sanitized)); return true; } else { hash = Md5Hash.Zero; return false; } } private static bool EventHasCallstackProperty(IssueEvent issueEvent) { return issueEvent.Lines.Any(x => FindNestedPropertyOfType(x, CallstackLogType) != null); } private static string? GetSummaryProperty(IssueEvent issueEvent) { StringBuilder? summary = null; foreach (JsonLogEvent logEvent in issueEvent.Lines) { JsonProperty? property = FindNestedPropertyOfType(logEvent, SummaryLogType); if (property != null) { if (summary == null) { summary = new StringBuilder(); } JsonElement value = property.Value.Value; if (value.ValueKind == JsonValueKind.String // handle LogValue type || (value.TryGetProperty(LogEventPropertyName.Text.Span, out value) && value.ValueKind == JsonValueKind.String)) { summary.Append(value.GetString() + '\n'); continue; } summary.Append(value.ToString() + '\n'); } else if (summary != null) { // when property is null but not summary, we early exit since we expect property split to be contiguous return summary.ToString(); } } return summary?.ToString(); } private static JsonProperty? FindNestedPropertyOfType(JsonLogEvent logEvent, string searchType) { JsonElement line = JsonDocument.Parse(logEvent.Data).RootElement; JsonElement properties; if (line.TryGetProperty("properties", out properties) && properties.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in properties.EnumerateObject()) { if (property.Name.StartsWith(searchType, System.StringComparison.OrdinalIgnoreCase)) { // if name is longer, check if it is a split property pattern: {name}${index} if (property.Name.Length > searchType.Length && property.Name.Substring(searchType.Length, 1) != "$") { continue; } return property; } } } return null; } /// public override bool HandleEvent(IssueEvent issueEvent) { if (issueEvent.EventId != null && IsMatchingEventId(issueEvent.EventId.Value)) { if (s_skipGauntletEvents.Contains(issueEvent.EventId.Value)) { // ignore Device event return true; } bool isSevereEvent = s_knownSevereGauntletEvents.Contains(issueEvent.EventId.Value); bool isTestEvent = !isSevereEvent && s_knownTestGauntletEvents.Contains(issueEvent.EventId.Value); bool isErrorEvent = issueEvent.Severity >= LogLevel.Error; if ((_wasSevereEventReported && !isSevereEvent) || (_wasTestEventReported && !isTestEvent && !isSevereEvent) || (_wasErrorEventReported && !isErrorEvent)) { return true; } string gauntletType = GetEventPrefix(issueEvent.EventId!.Value); IssueEventGroup issue = new IssueEventGroup($"Gauntlet:{gauntletType}", "Automation {Meta:GauntletType} {Severity} in {Meta:Node}", IssueChangeFilter.All); issue.Events.Add(issueEvent); bool hasCallstack; GetHash(issueEvent, issue.Keys, issue.Metadata, out hasCallstack); issue.Metadata.Add("GauntletType", gauntletType); if (hasCallstack) { string? hash = issue.Metadata.FindValues("Hash").FirstOrDefault(); issue.Type = $"{issue.Type}:with-callstack:{hash}"; } if (isErrorEvent && !_wasErrorEventReported) { // We've encountered an error event; // We can ignore other issues to prevent superfluous issues from being created _issues.RemoveAll((issue) => issue.Events.Any(x => x.Severity <= LogLevel.Warning)); _wasErrorEventReported = true; } if (isSevereEvent) { // We've encountered a severe event where either the engine has crashed or a build was not found. // We can ignore other issues to prevent superfluous issues from being created _issues.RemoveAll((issue) => !issue.Events.Any(x => s_knownSevereGauntletEvents.Contains(x.EventId!.Value))); _wasSevereEventReported = true; } else if (isTestEvent && !_wasTestEventReported) { // We've encountered a test event; // We can ignore other issues to prevent superfluous issues from being created _issues.RemoveAll((issue) => !issue.Events.Any(x => s_knownTestGauntletEvents.Contains(x.EventId!.Value))); _wasTestEventReported = true; } _issues.Add(issue); return true; } return false; } /// public override IEnumerable GetIssues() => _issues; } }