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