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();
}