// Copyright Epic Games, Inc. All Rights Reserved. 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 compile error /// [IssueHandler] public class HashedIssueHandler : IssueHandler { readonly IssueHandlerContext _context; readonly List _issueEvents = new List(); /// /// Known general events /// static readonly HashSet s_knownGeneralEvents = new HashSet { KnownLogEvents.Generic, KnownLogEvents.ExitCode, KnownLogEvents.Horde, KnownLogEvents.Horde_InvalidPreflight }; /// /// Constructor /// public HashedIssueHandler(IssueHandlerContext context) => _context = context; /// /// Determines if the given event is general and should be salted to make it unique /// /// The event id to compare /// True if the given event id matches public static bool IsGeneralEventId(EventId eventId) { return s_knownGeneralEvents.Contains(eventId) || (eventId.Id >= KnownLogEvents.Systemic.Id && eventId.Id <= KnownLogEvents.Systemic_Max.Id); } /// public override int Priority => 1; /// public override bool HandleEvent(IssueEvent logEvent) { _issueEvents.Add(logEvent); return true; } /// public override IEnumerable GetIssues() { List issues = new List(); IssueEventGroup? genericFingerprint = null; IssueEventGroup? genericErrorsFingerprint = null; HashSet hashes = new HashSet(); // keep hash consistent when only have general, non-unique events bool allGeneral = _issueEvents.FirstOrDefault(stepEvent => stepEvent.EventId == null || !IsGeneralEventId(stepEvent.EventId.Value)) == null; foreach (IssueEvent stepEvent in _issueEvents) { string hashSource = stepEvent.Render(); if (!allGeneral && stepEvent.EventId != null) { // If the event is general, salt the hash with the stream id, template, otherwise it will be aggressively matched. // Consider salting with node name, though template id should be enough and have better grouping if (IsGeneralEventId(stepEvent.EventId.Value)) { hashSource += $"step:{_context.StreamId}:{_context.TemplateId}"; } } if (hashes.Count < 25 && TryGetHash(hashSource, out Md5Hash hash)) { hashes.Add(hash); IssueEventGroup issue = new IssueEventGroup("Hashed", "{Severity} in {Meta:Node}", IssueChangeFilter.All); issue.Events.Add(stepEvent); issue.Keys.AddHash(hash); stepEvent.AuditLogger?.LogDebug("Fingerprint key: '{Key}' generated from event: '{Event}'", hash, stepEvent.Render()); AddFileMetadata(issue, stepEvent); issue.Metadata.Add("Node", _context.NodeName); issues.Add(issue); } else { if (stepEvent.Severity == LogLevel.Error || stepEvent.Severity == LogLevel.Critical) { if (genericErrorsFingerprint == null) { genericErrorsFingerprint = new IssueEventGroup("Hashed", "{Severity} in {Meta:Node}", IssueChangeFilter.All); genericErrorsFingerprint.Keys.Add(IssueKey.FromStepAndSeverity(_context.StreamId, _context.TemplateId, _context.NodeName, LogLevel.Error)); stepEvent.AuditLogger?.LogDebug("Fingerprint key: '{Key}' generated from event: '{Event}'", IssueKey.FromStepAndSeverity(_context.StreamId, _context.TemplateId, _context.NodeName, LogLevel.Error), stepEvent.Render()); genericErrorsFingerprint.Metadata.Add("Node", _context.NodeName); issues.Add(genericErrorsFingerprint); } genericErrorsFingerprint.Events.Add(stepEvent); AddFileMetadata(genericErrorsFingerprint, stepEvent); } else { if (genericFingerprint == null) { genericFingerprint = new IssueEventGroup("Hashed", "{Severity} in {Meta:Node}", IssueChangeFilter.All); genericFingerprint.Keys.Add(IssueKey.FromStep(_context.StreamId, _context.TemplateId, _context.NodeName)); stepEvent.AuditLogger?.LogDebug("Fingerprint key: '{Key}' generated from event: '{Event}'", IssueKey.FromStep(_context.StreamId, _context.TemplateId, _context.NodeName), stepEvent.Render()); genericFingerprint.Metadata.Add("Node", _context.NodeName); issues.Add(genericFingerprint); } genericFingerprint.Events.Add(stepEvent); AddFileMetadata(genericFingerprint, stepEvent); } } } return issues; } static void AddFileMetadata(IssueEventGroup issue, IssueEvent stepEvent) { try { foreach (JsonLogEvent line in stepEvent.Lines) { using JsonDocument document = JsonDocument.Parse(line.Data); JsonElement properties; if (document.RootElement.TryGetProperty("properties", out properties) && properties.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in properties.EnumerateObject()) { if (property.NameEquals("file") && property.Value.ValueKind == JsonValueKind.String) { string file = property.Value.GetString()!; int endIdx = file.LastIndexOfAny(new char[] { '/', '\\' }) + 1; string fileName = file.Substring(endIdx); issue.Metadata.Add("File", fileName); stepEvent.AuditLogger?.LogDebug("Metadata added for file: '{File}'", fileName); } } } } } catch (System.Exception ex) { stepEvent.AuditLogger?.LogError("Exception adding file metadata to issue: '{Exception}'", ex); } } static bool TryGetHash(string message, out Md5Hash hash) { string sanitized = message.ToUpperInvariant(); sanitized = Regex.Replace(sanitized, @"(? 30) { hash = Md5Hash.Compute(Encoding.UTF8.GetBytes(sanitized)); return true; } else { hash = Md5Hash.Zero; return false; } } } }