sqlinsight-pro/src/SQLInsight.Core/Data/ErrorLog.cs
Søren Eggert Lundsteen Olsen e6840e40d7 SQL Server error log without elevated rights (8a)
A five-minute collector reads important error log entries from the best
source the agent's rights allow: the error log itself (xp_readerrorlog,
only with securityadmin), the default trace's ErrorLog and Audit Login
Failed events (optional ALTER TRACE), or the exception and connectivity
ring buffers (VIEW SERVER STATE: numbers and severity, failed logins).
Repetitions are summed per error; the watermark is the newest entry
read, because the default trace is buffered. Stored in
insight.ErrorLogEvents (migration 0013, EventDays), resumed after restart.

Rules SQLI-LOG-001..006: corruption 823/824/825, I/O over 15 s (833),
severe errors (17+), login failure bursts (configurable), stack dumps and
AG errors; rules needing the log text are "not measurable" with the
right to grant. Page /instanser/{navn}/fejllog (menu Overvågning) and
GET /api/v1/instances/{name}/error-log with messages redacted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:30:41 +02:00

178 lines
8.1 KiB
C#

using System.Globalization;
using System.Text.RegularExpressions;
namespace SQLInsight.Core.Data;
/// <summary>Where the error log entries came from, best first. Each needs less than the one before, and sees less.</summary>
public enum ErrorLogSource
{
/// <summary>The error log itself (xp_readerrorlog): everything. Needs securityadmin, which the agent normally does not have.</summary>
ErrorLog,
/// <summary>The default trace's ErrorLog and Audit Login Failed events: every error log line. Needs ALTER TRACE (optional).</summary>
DefaultTrace,
/// <summary>The exception ring buffer: error numbers and severity, no text. Needs only VIEW SERVER STATE.</summary>
RingBuffer,
}
/// <summary>What kind of error log entry this is, for the rules.</summary>
public enum ErrorLogCategory
{
Other,
/// <summary>823, 824, 825: SQL Server read a damaged page or had to retry a read.</summary>
Corruption,
/// <summary>833: I/O requests taking longer than 15 seconds.</summary>
SlowIo,
/// <summary>18456: a login failed.</summary>
LoginFailed,
/// <summary>A stack dump (an internal SQL Server failure).</summary>
StackDump,
/// <summary>Always On availability group errors.</summary>
AvailabilityGroup,
/// <summary>Other errors of severity 17 and above: resources, software or hardware.</summary>
Severe,
}
/// <param name="Message">The text, when the source has it. Can name logins and client addresses: sensitive.</param>
public sealed record ErrorLogEntry(DateTimeOffset At, int? Error, int? Severity, string? Message);
/// <summary>The same error repeated, summed: one line per error number (and category) in a collection.</summary>
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; }
/// <summary>The newest entry read; the next collection continues after it.</summary>
public required DateTimeOffset ReadUntil { get; init; }
/// <summary>What was found since the previous collection, grouped.</summary>
public IReadOnlyList<ErrorLogGroup> Groups { get; init; } = [];
/// <summary>The groups of the last <see cref="ErrorLogAnalysis.RecentWindow"/>: they keep findings open.</summary>
public IReadOnlyList<ErrorLogGroup> Recent { get; init; } = [];
/// <summary>Why less is seen than the error log has (e.g. no ALTER TRACE); null with the full error log.</summary>
public string? Coverage { get; init; }
/// <summary>Login failures in the collection interval before a burst is a finding (SQLInsight:ErrorLog:LoginFailureBurst).</summary>
public int LoginFailureBurst { get; init; } = 20;
}
public static partial class ErrorLogAnalysis
{
/// <summary>How long an error keeps its finding open after it was last seen.</summary>
public static readonly TimeSpan RecentWindow = TimeSpan.FromHours(24);
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<ErrorLogEntry> ParseLines(IEnumerable<(DateTimeOffset At, string Text)> lines)
{
ArgumentNullException.ThrowIfNull(lines);
var entries = new List<ErrorLogEntry>();
(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;
}
/// <summary>Messages without a number line that still say which error they are (833, stack dumps, login failures).</summary>
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,
};
}
/// <summary>Sums repeated errors: one group per category and error number, with the first message as a sample.</summary>
public static IReadOnlyList<ErrorLogGroup> Group(IEnumerable<ErrorLogEntry> 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();
}
/// <summary>The groups of the last day: this collection's plus the earlier ones still in the window, merged.</summary>
public static IReadOnlyList<ErrorLogGroup> Recent(IReadOnlyList<ErrorLogGroup> previous, IReadOnlyList<ErrorLogGroup> 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();
}