using System.Globalization; using System.Text.RegularExpressions; namespace SQLInsight.Core.Data; /// Where the error log entries came from, best first. Each needs less than the one before, and sees less. public enum ErrorLogSource { /// The error log itself (xp_readerrorlog): everything. Needs securityadmin, which the agent normally does not have. ErrorLog, /// The default trace's ErrorLog and Audit Login Failed events: every error log line. Needs ALTER TRACE (optional). DefaultTrace, /// The exception ring buffer: error numbers and severity, no text. Needs only VIEW SERVER STATE. RingBuffer, } /// What kind of error log entry this is, for the rules. public enum ErrorLogCategory { Other, /// 823, 824, 825: SQL Server read a damaged page or had to retry a read. Corruption, /// 833: I/O requests taking longer than 15 seconds. SlowIo, /// 18456: a login failed. LoginFailed, /// A stack dump (an internal SQL Server failure). StackDump, /// Always On availability group errors. AvailabilityGroup, /// Other errors of severity 17 and above: resources, software or hardware. Severe, } /// The text, when the source has it. Can name logins and client addresses: sensitive. public sealed record ErrorLogEntry(DateTimeOffset At, int? Error, int? Severity, string? Message); /// The same error repeated, summed: one line per error number (and category) in a collection. public sealed record ErrorLogGroup(ErrorLogCategory Category, int? Error, int? Severity, int Count, DateTimeOffset FirstAt, DateTimeOffset LastAt, string? Sample); public sealed record ErrorLogSnapshot { public required DateTimeOffset CapturedAt { get; init; } public required ErrorLogSource Source { get; init; } /// The newest entry read; the next collection continues after it. public required DateTimeOffset ReadUntil { get; init; } /// What was found since the previous collection, grouped. public IReadOnlyList Groups { get; init; } = []; /// The groups of the last : they keep findings open. public IReadOnlyList Recent { get; init; } = []; /// Why less is seen than the error log has (e.g. no ALTER TRACE); null with the full error log. public string? Coverage { get; init; } /// Login failures in the collection interval before a burst is a finding (SQLInsight:ErrorLog:LoginFailureBurst). public int LoginFailureBurst { get; init; } = 20; } public static partial class ErrorLogAnalysis { /// How long an error keeps its finding open after it was last seen. public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24); /// /// Error log lines as the default trace or xp_readerrorlog give them: "Error: 824, Severity: 24, State: 2." is its own /// line, followed by the message. The number line is joined to the message that follows. /// public static IReadOnlyList ParseLines(IEnumerable<(DateTimeOffset At, string Text)> lines) { ArgumentNullException.ThrowIfNull(lines); var entries = new List(); (DateTimeOffset At, int Error, int Severity)? pending = null; foreach (var (at, raw) in lines) { var text = raw.Trim(); if (text.Length == 0) { continue; } if (ErrorLine().Match(text) is { Success: true } m) { if (pending is { } p) { entries.Add(new ErrorLogEntry(p.At, p.Error, p.Severity, null)); } pending = (at, int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture), int.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture)); continue; } if (pending is { } q) { entries.Add(new ErrorLogEntry(q.At, q.Error, q.Severity, text)); pending = null; continue; } entries.Add(new ErrorLogEntry(at, ErrorIn(text), null, text)); } if (pending is { } last) { entries.Add(new ErrorLogEntry(last.At, last.Error, last.Severity, null)); } return entries; } /// Messages without a number line that still say which error they are (833, stack dumps, login failures). private static int? ErrorIn(string text) => text.Contains("I/O requests taking longer than 15 seconds", StringComparison.OrdinalIgnoreCase) ? 833 : text.StartsWith("Login failed", StringComparison.OrdinalIgnoreCase) ? 18456 : null; public static ErrorLogCategory Categorize(ErrorLogEntry entry) { ArgumentNullException.ThrowIfNull(entry); var text = entry.Message ?? string.Empty; return entry.Error switch { 823 or 824 or 825 => ErrorLogCategory.Corruption, 833 => ErrorLogCategory.SlowIo, 18456 => ErrorLogCategory.LoginFailed, 17065 or 17066 or 17067 or 17310 => ErrorLogCategory.StackDump, 1480 or 19406 or 41142 or 41143 or 41144 or 41131 => ErrorLogCategory.AvailabilityGroup, >= 35201 and <= 35299 => ErrorLogCategory.AvailabilityGroup, _ when text.Contains("Stack Signature", StringComparison.OrdinalIgnoreCase) || text.Contains("SqlDumpExceptionHandler", StringComparison.OrdinalIgnoreCase) || text.Contains("Stack Dump", StringComparison.OrdinalIgnoreCase) => ErrorLogCategory.StackDump, _ when text.Contains("availability group", StringComparison.OrdinalIgnoreCase) && (entry.Severity >= 16 || text.Contains("failed", StringComparison.OrdinalIgnoreCase)) => ErrorLogCategory.AvailabilityGroup, _ when entry.Severity >= 17 => ErrorLogCategory.Severe, _ => ErrorLogCategory.Other, }; } /// Sums repeated errors: one group per category and error number, with the first message as a sample. public static IReadOnlyList Group(IEnumerable entries) { ArgumentNullException.ThrowIfNull(entries); return entries .Select(e => (Entry: e, Category: Categorize(e))) .Where(x => x.Category != ErrorLogCategory.Other) .GroupBy(x => (x.Category, x.Entry.Error)) .Select(g => new ErrorLogGroup(g.Key.Category, g.Key.Error, g.Max(x => x.Entry.Severity), g.Count(), g.Min(x => x.Entry.At), g.Max(x => x.Entry.At), Cut(g.Select(x => x.Entry.Message).FirstOrDefault(m => m is not null)))) .OrderByDescending(g => g.Category).ThenByDescending(g => g.Count) .ToList(); } /// The groups of the last day: this collection's plus the earlier ones still in the window, merged. public static IReadOnlyList Recent(IReadOnlyList previous, IReadOnlyList current, DateTimeOffset now) { ArgumentNullException.ThrowIfNull(previous); ArgumentNullException.ThrowIfNull(current); return previous.Where(g => now - g.LastAt < RecentWindow).Concat(current) .GroupBy(g => (g.Category, g.Error)) .Select(g => new ErrorLogGroup(g.Key.Category, g.Key.Error, g.Max(x => x.Severity), g.Sum(x => x.Count), g.Min(x => x.FirstAt), g.Max(x => x.LastAt), g.OrderByDescending(x => x.LastAt).Select(x => x.Sample).FirstOrDefault(s => s is not null))) .ToList(); } private static string? Cut(string? text) => text is { Length: > 500 } ? text[..500] : text; [GeneratedRegex(@"^Error:\s*(\d+),\s*Severity:\s*(\d+),\s*State:\s*\d+", RegexOptions.IgnoreCase)] private static partial Regex ErrorLine(); }