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>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 10:30:41 +02:00
parent cb2f8fcbce
commit e6840e40d7
24 changed files with 1002 additions and 5 deletions

View file

@ -4,7 +4,7 @@
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
> **Status:** Fase 1–4 er implementeret: agent, 66 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
> **Status:** Fase 1–4 er implementeret: agent, 72 indbyggede regler plus community-regler, notifikationer, lokalt repository og web-UI, licenser, signerede opdateringer, SQLInsight Hub med portal, erfaring på tværs af kunder, PDF-rapporter og integration med Prometheus, Grafana og SCOM, plus MSI-installer, community-websted og Discourse-forum. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [integration](docs/integration.md), [rapporter](docs/rapporter.md), [egne regler](docs/regler.md), [databehandling](docs/databehandling.md), [community](docs/community.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
---

View file

@ -107,7 +107,8 @@ EXEC (@Sql);
/*
Valgfrit: hvem og hvornår ved ændringer af serverroller.
Rettighedsrevisionen (sysadmin m.m.) virker uden. Med ALTER TRACE kan agenten desuden læse default trace og vise,
Rettighedsrevisionen (sysadmin m.m.) og fejlloggen virker uden. Med ALTER TRACE kan agenten desuden læse default trace og vise
fejltekster og mislykkede logins fra fejlloggen (docs/fejllog.md), og
HVEM der gav en ny rettighed og HVORNÅR. ALTER TRACE giver også adgang til at oprette traces, så det er et bevidst
valg; fjern kommentaren for at give den:

30
docs/fejllog.md Normal file
View file

@ -0,0 +1,30 @@
# SQL Servers fejllog
SQLInsight Pro læser vigtige hændelser fra SQL Servers fejllog hvert 5. minut og samler gentagelser: samme fejl flere gange bliver én linje med antal, første og seneste tidspunkt og et eksempel. Siden **Fejllog** (menuen Overvågning, eller knappen på instansens side) viser hændelserne for 1–90 dage, og de findes i API'et: `GET /api/v1/instances/{navn}/error-log?days=7` (eksemplet er `[udeladt]` uden `ApiIncludesSensitiveEvidence`).
## Hvad ses, med hvilke rettigheder
Agenten har ikke adgang til selve logfilen. Den bruger den bedste kilde, rettighederne giver:
| Kilde | Rettighed | Hvad ses |
|---|---|---|
| Fejlloggen (`xp_readerrorlog`) | `securityadmin` (gives **ikke** som standard) | Alt, også langsom I/O (833), læsegentagelser (825) og stack dumps |
| Default trace (hændelserne ErrorLog og Audit Login Failed) | `ALTER TRACE` (valgfri i `grant-least-privilege.sql`) | Fejl med severity 20 og derover med tekst, og mislykkede logins med login og klientadresse |
| Ring buffers (`sys.dm_os_ring_buffers`) | `VIEW SERVER STATE` (har agenten altid) | Fejlnumre og severity fra motorens exceptions (fx 823/824, 701) og mislykkede logins (2008+) – uden tekst |
Kan en regel ikke vurderes med den kilde, agenten har, er den "ikke målbar" med årsagen: 833 og stack dumps kræver selve fejlloggen.
## Regler
| Regel | Alvor | Hvornår |
|---|---|---|
| SQLI-LOG-001 | Kritisk (823/824), Høj (825) | Beskadigede sider eller læsninger, der måtte gentages |
| SQLI-LOG-002 | Høj | I/O over 15 sekunder (833) |
| SQLI-LOG-003 | Middel (severity 17–19), Høj (20+) | Andre alvorlige fejl |
| SQLI-LOG-004 | Middel | Mindst 20 mislykkede logins inden for en måling (5 minutter); `SQLInsight:ErrorLog:LoginFailureBurst` |
| SQLI-LOG-005 | Høj | Stack dumps |
| SQLI-LOG-006 | Høj | Fejl i Always On availability groups |
Fund er åbne i et døgn efter seneste forekomst. Fejlmeddelelser kan indeholde loginnavne og klientadresser: eksemplet er en følsom måling, som ikke sendes til SQLInsight Hub. Hændelserne gemmes lokalt i `EventDays` (standard 90 dage).
SQL Server skriver default trace i buffere, så en hændelse kan først blive læsbar et par minutter efter; agenten fortsætter fra den nyeste hændelse, den har læst, så intet springes over.

View file

@ -24,6 +24,8 @@ public sealed class AgentOptions
public TopSqlOptions TopSql { get; set; } = new();
public ErrorLogOptions ErrorLog { get; set; } = new();
public IList<MonitoredInstance> Instances { get; } = [];
}
@ -86,3 +88,10 @@ public sealed class TopSqlOptions
/// </summary>
public double ActivityThresholdPercent { get; set; } = 50;
}
/// <summary>The SQL Server error log.</summary>
public sealed class ErrorLogOptions
{
/// <summary>Failed logins within one collection (5 minutes) before it is a finding (SQLI-LOG-004).</summary>
public int LoginFailureBurst { get; set; } = 20;
}

View file

@ -246,6 +246,12 @@ public sealed partial class InstanceMonitor(
return await repository.GetSnapshotAsync<Core.Data.LongOperationSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
// The error log continues where it stopped, so nothing written while the agent was down is missed.
if (collector is Collectors.Instance.ErrorLogCollector)
{
return await repository.GetSnapshotAsync<Core.Data.ErrorLogSnapshot>(instanceId, collector.Name, cancellationToken).ConfigureAwait(false);
}
if (collector is not Collectors.Concurrency.DeadlockCollector)
{
return null;

View file

@ -32,7 +32,7 @@ public sealed partial class MonitoringService(
var intervals = new Dictionary<string, TimeSpan>(settings.CollectorIntervals, StringComparer.OrdinalIgnoreCase);
var monitors = settings.Instances
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent)), intervals, connectionFactory, ruleEngine, repository,
.Select(i => new InstanceMonitor(i, BuiltInCollectors.Create(customRules, new CollectorSettings(groupResolver, settings.Privileges.MaxSysadminAccounts, settings.TopSql.ActivityThresholdPercent, settings.ErrorLog.LoginFailureBurst)), intervals, connectionFactory, ruleEngine, repository,
loggerFactory.CreateLogger($"SQLInsight.Instance.{i.Name}"), notifications, notificationOptions.Value.WebUrl, hubOutbox))
.ToList();
if (monitors.Count == 0)

View file

@ -27,6 +27,7 @@ public static class BuiltInCollectors
new QueryStatsCollector(settings.ActivityThresholdPercent),
new WaitSamplingCollector(),
new LongOperationCollector(),
new ErrorLogCollector(settings.LoginFailureBurst),
new PerformanceCounterCollector(),
new CpuCollector(),
new BlockingCollector(),
@ -45,7 +46,9 @@ public static class BuiltInCollectors
/// <param name="Groups">Unfolds Windows groups with server rights to the people in them (Active Directory).</param>
/// <param name="MaxSysadminAccounts">More people and accounts with sysadmin than this is a finding.</param>
/// <param name="ActivityThresholdPercent">A statement using more CPU time than this share of its lifetime is a finding.</param>
/// <param name="LoginFailureBurst">Failed logins within one collection (5 minutes) before it is a finding.</param>
public sealed record CollectorSettings(
Core.Data.IGroupMemberResolver? Groups = null,
int MaxSysadminAccounts = 3,
double ActivityThresholdPercent = 50);
double ActivityThresholdPercent = 50,
int LoginFailureBurst = 20);

View file

@ -0,0 +1,169 @@
using System.Data.Common;
using System.Globalization;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
namespace SQLInsight.Collectors.Instance;
/// <summary>
/// The SQL Server error log since the previous collection, from the best source the agent's rights allow:
/// the error log itself (securityadmin), the default trace's ErrorLog and Audit Login Failed events (ALTER TRACE, an
/// optional grant), or the exception ring buffer (VIEW SERVER STATE: error numbers and severity, no text).
/// Repeated errors are summed. Read only; SQL Server 2005+.
/// </summary>
public sealed class ErrorLogCollector(int loginFailureBurst = 20) : Collector<ErrorLogSnapshot>
{
/// <summary>On the first collection, this far back.</summary>
public static readonly TimeSpan FirstLookBack = TimeSpan.FromHours(1);
internal const string CanReadErrorLogSql = """
SELECT CASE WHEN IS_SRVROLEMEMBER(N'securityadmin') = 1 OR IS_SRVROLEMEMBER(N'sysadmin') = 1 THEN 1 ELSE 0 END,
DATEDIFF(minute, GETUTCDATE(), GETDATE()), GETUTCDATE()
""";
// The exception ring buffer: every exception raised, with number and severity. ms_ticks is the clock it counts in.
internal const string RingBufferSql = """
SELECT DATEADD(second, -CAST((i.ms_ticks - rb.timestamp) / 1000 AS int), GETUTCDATE()),
rb.x.value('(//Exception/Error)[1]', 'int'), rb.x.value('(//Exception/Severity)[1]', 'int')
FROM (SELECT timestamp, CAST(record AS xml) AS x FROM sys.dm_os_ring_buffers WHERE ring_buffer_type = N'RING_BUFFER_EXCEPTION') rb
CROSS JOIN sys.dm_os_sys_info i
""";
// Failed logins in the connectivity ring buffer (SQL Server 2008+): error 18456 with its state and client address.
internal const string ConnectivitySql = """
SELECT DATEADD(second, -CAST((i.ms_ticks - rb.timestamp) / 1000 AS int), GETUTCDATE()),
rb.x.value('(//SniConsumerError)[1]', 'int'), rb.x.value('(//State)[1]', 'int'), rb.x.value('(//RemoteHost)[1]', 'nvarchar(128)')
FROM (SELECT timestamp, CAST(record AS xml) AS x FROM sys.dm_os_ring_buffers
WHERE ring_buffer_type = N'RING_BUFFER_CONNECTIVITY' AND record LIKE N'%<SniConsumerError>18456</SniConsumerError>%') rb
CROSS JOIN sys.dm_os_sys_info i
""";
public const string DefaultTraceCoverage =
"Fejl med severity 20 og derover og mislykkede logins fra SQL Servers default trace. Langsom I/O (833), læsegentagelser (825) og stack dumps "
+ "står kun i selve fejlloggen, som kræver rollen securityadmin (xp_readerrorlog); SQLInsight giver ikke agenten den som standard.";
public const string RingBufferCoverage =
"Kun fejlnumre og alvor (uden tekst) fra SQL Servers exception ring buffer og mislykkede logins fra connectivity ring buffer; langsom I/O (833), "
+ "læsegentagelser (825) og stack dumps ses ikke. "
+ "Giv agenten ALTER TRACE (valgfrit i grant-least-privilege.sql) for fejltekster og alvorlige fejl via default trace.";
public override string Name => "error-log";
public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected override async Task<ErrorLogSnapshot> CollectAsync(CollectorContext context, ErrorLogSnapshot? previous)
{
var ct = context.CancellationToken;
var connection = context.Connection;
var (errorLog, offsetMinutes, serverUtc) = (await Db.QueryAsync(connection, CanReadErrorLogSql, r => (r.Int(0) == 1, r.Int(1), r.DateTime(2)), ct)
.ConfigureAwait(false))[0];
var now = new DateTimeOffset(DateTime.SpecifyKind(serverUtc, DateTimeKind.Utc));
var since = previous?.ReadUntil ?? now - FirstLookBack;
var offset = TimeSpan.FromMinutes(offsetMinutes);
List<ErrorLogEntry> entries;
ErrorLogSource source;
string? coverage = null;
if (errorLog && await ReadErrorLogAsync(connection, since, now, offset, ct).ConfigureAwait(false) is { } fromLog)
{
(entries, source) = (fromLog, ErrorLogSource.ErrorLog);
}
else if (await ReadDefaultTraceAsync(connection, since, offset, ct).ConfigureAwait(false) is { } fromTrace)
{
(entries, source, coverage) = (fromTrace, ErrorLogSource.DefaultTrace, DefaultTraceCoverage);
}
else
{
entries = (await Db.QueryAsync(connection, RingBufferSql, r => new ErrorLogEntry(Utc(r.DateTime(0)), r.NullableInt(1), r.NullableInt(2), null), ct)
.ConfigureAwait(false)).Where(e => e.At > since).ToList();
if (context.Instance.Version.Major >= 10)
{
entries.AddRange((await Db.QueryAsync(connection, ConnectivitySql, r => new ErrorLogEntry(Utc(r.DateTime(0)), 18456, 14,
$"Login failed (state {r.NullableInt(2)}) [CLIENT: {r.NullableString(3)}]"), ct).ConfigureAwait(false)).Where(e => e.At > since));
}
(source, coverage) = (ErrorLogSource.RingBuffer, RingBufferCoverage);
}
var groups = ErrorLogAnalysis.Group(entries);
return new ErrorLogSnapshot
{
CapturedAt = context.Now,
Source = source,
// The newest entry read, not the clock: the default trace is written in buffers, and entries can become readable after later collections.
ReadUntil = entries.Count == 0 ? since : entries.Max(e => e.At),
Groups = groups,
Recent = ErrorLogAnalysis.Recent(previous?.Recent ?? [], groups, context.Now),
Coverage = coverage,
LoginFailureBurst = loginFailureBurst,
};
}
/// <summary>The current error log (xp_readerrorlog), or null when it cannot be read.</summary>
private static async Task<List<ErrorLogEntry>?> ReadErrorLogAsync(DbConnection connection, DateTimeOffset since, DateTimeOffset until, TimeSpan offset,
CancellationToken cancellationToken)
{
try
{
var from = Local(since, offset);
var to = Local(until.AddMinutes(1), offset);
var lines = await Db.QueryAsync(connection, $"EXEC master.dbo.xp_readerrorlog 0, 1, NULL, NULL, '{from}', '{to}'",
r => (Utc(r.DateTime(0)) - offset, r.NullableString(2) ?? string.Empty), cancellationToken).ConfigureAwait(false);
return [.. ErrorLogAnalysis.ParseLines(lines.Where(l => l.Item1 > since).Select(l => (l.Item1, l.Item2)))];
}
catch (DbException)
{
return null;
}
}
/// <summary>ErrorLog (22) and Audit Login Failed (20) events from the default trace, or null without ALTER TRACE.</summary>
private static async Task<List<ErrorLogEntry>?> ReadDefaultTraceAsync(DbConnection connection, DateTimeOffset since, TimeSpan offset,
CancellationToken cancellationToken)
{
try
{
var path = (await Db.QueryAsync(connection, PrivilegeCollector.DefaultTracePathSql, r => r.NullableString(0), cancellationToken).ConfigureAwait(false))
.FirstOrDefault();
if (path is null)
{
return null;
}
var sql = $"""
SELECT StartTime, EventClass, Error, Severity, CAST(TextData AS nvarchar(2000))
FROM sys.fn_trace_gettable({Db.Literal(PrivilegeAnalysis.TraceFileBase(path))}, DEFAULT)
WHERE EventClass IN (20, 22) AND StartTime > '{Local(since, offset)}'
""";
var rows = await Db.QueryAsync(connection, sql, r => (At: Utc(r.DateTime(0)) - offset, Class: r.Int(1), Error: r.NullableInt(2), Severity: r.NullableInt(3),
Text: r.NullableString(4)), cancellationToken).ConfigureAwait(false);
var entries = new List<ErrorLogEntry>();
foreach (var row in rows)
{
if (row.Class == 20)
{
entries.Add(new ErrorLogEntry(row.At, 18456, row.Severity ?? 14, row.Text));
}
else if (row.Error is > 0)
{
entries.Add(new ErrorLogEntry(row.At, row.Error, row.Severity, row.Text));
}
else
{
entries.AddRange(ErrorLogAnalysis.ParseLines([(row.At, row.Text ?? string.Empty)]));
}
}
return entries;
}
catch (DbException)
{
return null;
}
}
private static string Local(DateTimeOffset utc, TimeSpan offset) =>
(utc.UtcDateTime + offset).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
private static DateTimeOffset Utc(DateTime value) => new(DateTime.SpecifyKind(value, DateTimeKind.Utc));
}

View file

@ -0,0 +1,178 @@
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();
}

View file

@ -0,0 +1,58 @@
using System.Globalization;
using System.Text.Json;
using Dapper;
using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
namespace SQLInsight.Repository;
/// <summary>An error log group as stored. <see cref="Sample"/> is sensitive (local UI only).</summary>
public sealed record StoredErrorLogEvent(long EventId, DateTime CollectedAt, string Category, int? Error, int? Severity, int Count, DateTime FirstAt, DateTime LastAt,
string? Sample, string Source);
public sealed partial class InsightRepository
{
private static async Task SaveErrorLogAsync(SqlConnection connection, int instanceId, ErrorLogSnapshot snapshot)
{
if (snapshot.Groups.Count == 0)
{
return;
}
await connection.ExecuteAsync("""
INSERT insight.ErrorLogEvents (InstanceId, CollectedAt, Category, Error, Severity, [Count], FirstAt, LastAt, Sample, Source)
SELECT @instanceId, @at, Category, Error, Severity, [Count], FirstAt, LastAt, Sample, @source
FROM OPENJSON(@json) WITH (Category varchar(32), Error int, Severity int, [Count] int, FirstAt datetime2(0), LastAt datetime2(0), Sample nvarchar(500));
""", new
{
instanceId,
at = Utc(snapshot.CapturedAt),
source = snapshot.Source.ToString(),
json = JsonSerializer.Serialize(snapshot.Groups.Select(g => new
{
Category = g.Category.ToString(),
g.Error,
g.Severity,
g.Count,
FirstAt = Iso(g.FirstAt),
LastAt = Iso(g.LastAt),
Sample = g.Sample is { Length: > 500 } s ? s[..500] : g.Sample,
}), Json),
}).ConfigureAwait(false);
static string Iso(DateTimeOffset value) => Utc(value).ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture);
}
/// <summary>Error log groups last seen in [from, to], newest first.</summary>
public async Task<IReadOnlyList<StoredErrorLogEvent>> GetErrorLogEventsAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, int top = 500,
CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
var rows = await connection.QueryAsync<StoredErrorLogEvent>("""
SELECT TOP (@top) EventId, CollectedAt, Category, Error, Severity, [Count], FirstAt, LastAt, Sample, Source
FROM insight.ErrorLogEvents WHERE InstanceId = @instanceId AND LastAt >= @from AND FirstAt <= @to
ORDER BY LastAt DESC, EventId DESC;
""", new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
return rows.ToList();
}
}

View file

@ -90,6 +90,7 @@ public sealed partial class InsightRepository
await DeleteInBatchesAsync(connection, "insight.DiskHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Deadlocks", "OccurredAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ErrorLogEvents", "LastAt < @cutoff", events).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.Findings", "ResolvedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.ConfigurationChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.PrivilegeChanges", "DetectedAt < @cutoff", resolved).ConfigureAwait(false);

View file

@ -264,6 +264,10 @@ public sealed partial class InsightRepository(string connectionString)
await SaveLongOperationsAsync(connection, instanceId, operations).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case ErrorLogSnapshot errorLog:
await SaveErrorLogAsync(connection, instanceId, errorLog).ConfigureAwait(false);
await SaveSnapshotJsonAsync(connection, instanceId, kind, data).ConfigureAwait(false);
return;
case PrivilegeSnapshot privileges:
await SavePrivilegeChangesAsync(connection, instanceId, privileges.Changes).ConfigureAwait(false);
await SaveLatePrivilegeAuthorsAsync(connection, instanceId, privileges.RecentChanges).ConfigureAwait(false);

View file

@ -0,0 +1,18 @@
/* Important SQL Server error log entries, summed per collection and error (corruption, slow I/O, severe errors, login
failures, stack dumps, AG). Sample is a message and can name logins and addresses: local only. Kept EventDays. */
CREATE TABLE insight.ErrorLogEvents
(
EventId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ErrorLogEvents PRIMARY KEY NONCLUSTERED,
InstanceId int NOT NULL,
CollectedAt datetime2(0) NOT NULL,
Category varchar(32) NOT NULL,
Error int NULL,
Severity int NULL,
[Count] int NOT NULL,
FirstAt datetime2(0) NOT NULL,
LastAt datetime2(0) NOT NULL,
Sample nvarchar(500) NULL,
Source varchar(16) NOT NULL
);
CREATE CLUSTERED INDEX CX_ErrorLogEvents ON insight.ErrorLogEvents (InstanceId, LastAt);

View file

@ -4,6 +4,7 @@ using SQLInsight.Rules.Configuration;
using SQLInsight.Rules.Databases;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Indexes;
using SQLInsight.Rules.Instance;
using SQLInsight.Rules.Lifecycle;
using SQLInsight.Rules.Performance;
using SQLInsight.Rules.Security;
@ -56,6 +57,12 @@ public static class BuiltInRules
new CpuRule(),
new ConstantActivityRule(),
new SlowBackupRule(),
new CorruptionErrorRule(),
new SlowIoErrorRule(),
new SevereErrorRule(),
new LoginFailureBurstRule(),
new StackDumpRule(),
new AvailabilityGroupErrorRule(),
new MemoryGrantsPendingRule(),
new AnomalyRule(),
new PlanRegressionRule(),

View file

@ -0,0 +1,144 @@
using System.Globalization;
using SQLInsight.Core.Collection;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Engine;
namespace SQLInsight.Rules.Instance;
/// <summary>
/// Important entries in the SQL Server error log, summed per error number and kept open for a day after they were
/// last seen. Messages can name logins and client addresses: they are sensitive evidence.
/// </summary>
public abstract class ErrorLogRule(ErrorLogCategory category, bool needsText) : Rule
{
public override IReadOnlyCollection<Capability> RequiredCapabilities => [Capability.DynamicManagementViews];
protected abstract Severity SeverityOf(ErrorLogGroup group);
protected abstract string TitleOf(ErrorLogGroup group);
protected abstract string Explanation { get; }
protected abstract string Recommendation { get; }
protected virtual IEnumerable<ErrorLogGroup> Relevant(ErrorLogSnapshot snapshot) => snapshot.Recent.Where(g => g.Category == category);
public override RuleEvaluation Evaluate(RuleContext context)
{
ArgumentNullException.ThrowIfNull(context);
var snapshot = context.Data.Get<ErrorLogSnapshot>();
if (needsText && snapshot.Source != ErrorLogSource.ErrorLog)
{
throw new DataUnavailableException(snapshot.Coverage ?? ErrorLogCollectorText.Coverage);
}
return Result(Relevant(snapshot).Select(g => CreateFinding(
SeverityOf(g),
TitleOf(g),
Explanation,
Recommendation,
[
new Evidence("Fejl", g.Error?.ToString(CultureInfo.InvariantCulture) ?? "–"),
new Evidence("Alvor (severity)", g.Severity?.ToString(CultureInfo.InvariantCulture) ?? "–"),
new Evidence("Antal", g.Count.ToString(CultureInfo.InvariantCulture)),
new Evidence("Første", Utc(g.FirstAt)),
new Evidence("Seneste", Utc(g.LastAt)),
new Evidence("Eksempel", g.Sample ?? "(kilden har ikke teksten)", Sensitive: true),
new Evidence("Kilde", snapshot.Source switch { ErrorLogSource.ErrorLog => "fejlloggen", ErrorLogSource.DefaultTrace => "default trace", _ => "exception ring buffer" }),
],
subject: $"errorlog: {category}/{g.Error}")));
}
private static string Utc(DateTimeOffset value) => value.UtcDateTime.ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture);
}
internal static class ErrorLogCollectorText
{
public const string Coverage = "Fejlloggen kan ikke læses fuldt ud med agentens rettigheder. Giv agenten ALTER TRACE (valgfrit i grant-least-privilege.sql).";
}
public sealed class CorruptionErrorRule() : ErrorLogRule(ErrorLogCategory.Corruption, needsText: false)
{
public override string Id => "SQLI-LOG-001";
public override string Title => "SQL Server har læst beskadigede sider (fejl 823/824/825)";
protected override Severity SeverityOf(ErrorLogGroup group) => group.Error == 825 ? Severity.High : Severity.Critical;
protected override string TitleOf(ErrorLogGroup group) => group.Error == 825
? $"SQL Server måtte læse sider igen for at få dem (fejl 825, {group.Count} gange)"
: $"SQL Server har læst beskadigede sider (fejl {group.Error}, {group.Count} gange)";
protected override string Explanation =>
"823 og 824 betyder, at en side i en database var beskadiget eller ikke kunne læses: data kan være tabt. 825 betyder, at læsningen først lykkedes "
+ "efter flere forsøg, et tidligt varsel om fejlende storage. Begge peger næsten altid på disk, controller, driver eller SAN.";
protected override string Recommendation =>
"Kør DBCC CHECKDB på databasen nu, og sørg for, at der findes en god backup og log-backups. Tjek Windows' systemlog og storage for fejl. "
+ "Se msdb.dbo.suspect_pages for de berørte sider. Ret ikke med REPAIR_ALLOW_DATA_LOSS uden at have talt med en DBA.";
}
public sealed class SlowIoErrorRule() : ErrorLogRule(ErrorLogCategory.SlowIo, needsText: true)
{
public override string Id => "SQLI-LOG-002";
public override string Title => "I/O-forespørgsler, der tager over 15 sekunder (fejl 833)";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.High;
protected override string TitleOf(ErrorLogGroup group) => $"Storage har brugt over 15 sekunder på I/O ({group.Count} gange)";
protected override string Explanation =>
"SQL Server skriver fejl 833, når en læsning eller skrivning har ventet over 15 sekunder. Brugerne oplever det som hængende forespørgsler, "
+ "og det skyldes næsten altid storage: overbelastede diske, SAN, multipathing, en virtuel disk på en travl host eller en backup der kører samtidig.";
protected override string Recommendation =>
"Se hvilken fil og hvilket drev meddelelsen nævner (Diskhastighed pr. drev), og tjek storage og hosten på tidspunktet. Fejlen skal ikke ignoreres.";
}
public sealed class SevereErrorRule() : ErrorLogRule(ErrorLogCategory.Severe, needsText: false)
{
public override string Id => "SQLI-LOG-003";
public override string Title => "Alvorlige fejl i SQL Servers fejllog (severity 17 og derover)";
protected override Severity SeverityOf(ErrorLogGroup group) => group.Severity >= 20 ? Severity.High : Severity.Medium;
protected override string TitleOf(ErrorLogGroup group) => $"Fejl {group.Error} med severity {group.Severity} ({group.Count} gange)";
protected override string Explanation =>
"Severity 17–19 er fejl, brugeren ikke selv kan rette (mangel på ressourcer som hukommelse, låse eller plads). Severity 20 og derover er fatale: "
+ "forbindelsen afbrydes, og ved 22–25 kan databasen eller instansen være berørt.";
protected override string Recommendation =>
"Slå fejlnummeret op (Microsofts liste over databasemotorfejl) og se meddelelsen i fejlloggen. Gentagne fejl af samme slags skal undersøges.";
}
public sealed class LoginFailureBurstRule() : ErrorLogRule(ErrorLogCategory.LoginFailed, needsText: false)
{
public override string Id => "SQLI-LOG-004";
public override string Title => "Mange mislykkede logins";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.Medium;
protected override string TitleOf(ErrorLogGroup group) => $"{group.Count} mislykkede logins på kort tid";
protected override string Explanation =>
"Mange mislykkede logins (fejl 18456) på kort tid er enten en applikation eller tjeneste med et forkert eller udløbet kodeord, eller et forsøg på at gætte kodeord.";
protected override string Recommendation =>
"Se fejlloggen for login og klientadresse (State fortæller hvorfor: 8 = forkert kodeord, 5 = ukendt login, 38 = database findes ikke). "
+ "Ret applikationens forbindelse, eller blokér adressen, hvis det er et angreb. Overvej Windows-godkendelse og at deaktivere sa.";
/// <summary>Only a burst in the latest interval, not the sum of a whole day.</summary>
protected override IEnumerable<ErrorLogGroup> Relevant(ErrorLogSnapshot snapshot) =>
snapshot.Groups.Where(g => g.Category == ErrorLogCategory.LoginFailed && g.Count >= snapshot.LoginFailureBurst);
}
public sealed class StackDumpRule() : ErrorLogRule(ErrorLogCategory.StackDump, needsText: true)
{
public override string Id => "SQLI-LOG-005";
public override string Title => "SQL Server har lavet et stack dump";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.High;
protected override string TitleOf(ErrorLogGroup group) => $"SQL Server har lavet stack dump ({group.Count} gange)";
protected override string Explanation =>
"Et stack dump betyder en intern fejl i SQL Server (access violation, assertion, en tråd der ikke svarer). Det kan skyldes en kendt fejl "
+ "rettet i en senere CU, tredjepartssoftware i processen eller hardware.";
protected override string Recommendation =>
"Find SQLDump*.mdmp/.txt i SQL Servers logmappe og meddelelsen i fejlloggen. Installér seneste CU (se versionsfund), og kontakt Microsoft eller en DBA, hvis det gentager sig.";
}
public sealed class AvailabilityGroupErrorRule() : ErrorLogRule(ErrorLogCategory.AvailabilityGroup, needsText: false)
{
public override string Id => "SQLI-LOG-006";
public override string Title => "Fejl i Always On availability groups";
protected override Severity SeverityOf(ErrorLogGroup group) => Severity.High;
protected override string TitleOf(ErrorLogGroup group) => $"Availability group-fejl {group.Error} ({group.Count} gange)";
protected override string Explanation =>
"Fejlloggen viser fejl fra Always On: failover, tabt forbindelse til en replika, lease-timeout eller en database, der ikke synkroniserer.";
protected override string Recommendation =>
"Se AG-fundene og AG-dashboardet i SSMS, Windows-clusterets log og netværket mellem replikaerne på tidspunktet.";
}

View file

@ -46,6 +46,7 @@ public static class ApiV1Endpoints
api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
api.MapGet("/instances/{name}/error-log", ErrorLogAsync).WithSummary("Vigtige hændelser i fejlloggen / Important error log events (days)");
api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
}
@ -493,6 +494,27 @@ public static class ApiV1Endpoints
return Results.Ok(AuditJson(audit, RedactorFor(options)));
}
private static async Task<IResult> ErrorLogAsync(string name, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct, int? days = null)
{
if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
{
return Results.NotFound();
}
var redact = RedactorFor(options);
var now = DateTimeOffset.UtcNow;
var snapshot = await repository.GetSnapshotAsync<ErrorLogSnapshot>(instance.InstanceId, "error-log", ct).ConfigureAwait(false);
var events = await repository.GetErrorLogEventsAsync(instance.InstanceId, now.AddDays(-Math.Clamp(days ?? 7, 1, 90)), now, 500, ct).ConfigureAwait(false);
return Results.Ok(new
{
Instance = instance.Name,
Source = snapshot?.Source.ToString(),
snapshot?.Coverage,
ReadAt = snapshot?.CapturedAt,
Events = events.Select(e => new { e.Category, e.Error, e.Severity, e.Count, e.FirstAt, e.LastAt, Sample = redact.Value(e.Sample), e.Source }),
});
}
private static async Task<IResult> WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions<WebOptions> options, CancellationToken ct,
DateTimeOffset? from = null, DateTimeOffset? to = null)
{

View file

@ -42,6 +42,7 @@
[
new("Overblik", "/"),
new("Alle fund", "/fund", "/fund/"),
new("Fejllog", "/vaelg/fejllog", "/fejllog"),
]),
new("analyse", "Analyse", "chart",
[

View file

@ -43,6 +43,7 @@ else
["ventetyper"] = ("Ventetyper", "#ventetider"),
["disk"] = ("Diskhastighed", "#disk"),
["konfiguration"] = ("Konfiguration", "/konfiguration"),
["fejllog"] = ("Fejllog", "/fejllog"),
};
[Parameter] public string Side { get; set; } = string.Empty;

View file

@ -0,0 +1,105 @@
@page "/instanser/{Name}/fejllog"
@inject InsightRepository Repository
@inject Display Display
@if (_instance is null)
{
<PageTitle>Ukendt instans · SQLInsight Pro</PageTitle>
<h1>Ukendt instans</h1>
<p>Der findes ingen instans med navnet <strong>@Name</strong>.</p>
return;
}
<PageTitle>Fejllog · @_instance.Name · SQLInsight Pro</PageTitle>
<p class="crumbs"><a href="/">Instanser</a> / <a href="/instanser/@Uri.EscapeDataString(_instance.Name)">@_instance.Name</a> / Fejllog</p>
<h1>Fejllog</h1>
<p class="lead">Vigtige hændelser i SQL Servers fejllog: beskadigede sider, langsom I/O, alvorlige fejl, mislykkede logins, stack dumps og availability group-fejl. Gentagelser er lagt sammen. Læses hvert 5. minut.</p>
@if (_snapshot is { } s)
{
<p class="muted">Kilde: @SourceText(s.Source), senest læst @Display.Ago(s.CapturedAt.UtcDateTime).</p>
@if (s.Coverage is { } coverage)
{
<div class="alert warn">@coverage</div>
}
}
else
{
<p class="muted">Fejlloggen er endnu ikke læst. Den læses første gang inden for 5 minutter efter opgradering.</p>
}
<section class="card">
<div class="card-head"><h2>Hændelser</h2><span class="muted">seneste @Dage dage</span></div>
<PeriodLinks Days="Dage!.Value" />
@if (_events.Count == 0)
{
<p class="muted">Ingen vigtige hændelser i perioden.</p>
}
else
{
<table>
<thead><tr><th>Seneste</th><th>Type</th><th class="num">Fejl</th><th class="num">Severity</th><th class="num">Antal</th><th>Eksempel</th></tr></thead>
<tbody>
@foreach (var e in _events)
{
<tr class="@(e.Category is "Corruption" or "StackDump" ? "bad" : "")">
<td class="nowrap">@Display.Time(e.LastAt)</td>
<td>@CategoryText(e.Category)</td>
<td class="num">@(e.Error?.ToString() ?? "–")</td>
<td class="num">@(e.Severity?.ToString() ?? "–")</td>
<td class="num">@e.Count</td>
<td class="mono small">@(e.Sample ?? "–")</td>
</tr>
}
</tbody>
</table>
}
</section>
<section class="card">
<details>
<summary>Hvad betyder det?</summary>
<p>SQL Servers fejllog er instansens dagbog. SQLInsight Pro læser den uden at have adgang til selve filen: med rettigheden ALTER TRACE (valgfri i grant-least-privilege.sql) via default trace, som har hver linje, og ellers kun fejlnumre fra SQL Servers exception ring buffer. Beskadigede sider (823/824/825) og I/O over 15 sekunder (833) peger næsten altid på storage og skal undersøges med det samme.</p>
</details>
</section>
@code {
[Parameter] public string Name { get; set; } = string.Empty;
[SupplyParameterFromQuery] public int? Dage { get; set; }
private InstanceOverview? _instance;
private ErrorLogSnapshot? _snapshot;
private IReadOnlyList<StoredErrorLogEvent> _events = [];
protected override async Task OnParametersSetAsync()
{
Dage = Math.Clamp(Dage ?? 7, 1, 90);
_instance = await Repository.GetInstanceAsync(Name);
if (_instance is null)
{
return;
}
_snapshot = await Repository.GetSnapshotAsync<ErrorLogSnapshot>(_instance.InstanceId, "error-log");
var now = DateTimeOffset.UtcNow;
_events = await Repository.GetErrorLogEventsAsync(_instance.InstanceId, now.AddDays(-Dage.Value), now);
}
public static string SourceText(ErrorLogSource source) => source switch
{
ErrorLogSource.ErrorLog => "fejlloggen",
ErrorLogSource.DefaultTrace => "default trace (hele fejlloggen)",
_ => "exception ring buffer (kun fejlnumre)",
};
public static string CategoryText(string category) => category switch
{
"Corruption" => "Beskadigede sider",
"SlowIo" => "I/O over 15 s",
"LoginFailed" => "Mislykkede logins",
"StackDump" => "Stack dump",
"AvailabilityGroup" => "Availability group",
"Severe" => "Alvorlig fejl",
_ => category,
};
}

View file

@ -21,6 +21,7 @@
<p class="report-links">
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/konfiguration">Konfiguration</a>
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/rettigheder">Rettigheder</a>
<a class="button" href="/instanser/@Uri.EscapeDataString(_instance.Name)/fejllog">Fejllog</a>
</p>
<PrivilegeAlert Findings="_findings" />

View file

@ -0,0 +1,54 @@
using SQLInsight.Core.Data;
namespace SQLInsight.Core.Tests;
public class ErrorLogTests
{
private static readonly DateTimeOffset T = new(2026, 10, 2, 8, 0, 0, TimeSpan.Zero);
[Fact]
public void Number_lines_are_joined_to_the_message_that_follows()
{
var entries = ErrorLogAnalysis.ParseLines(
[
(T, "Error: 824, Severity: 24, State: 2."),
(T, "SQL Server detected a logical consistency-based I/O error: incorrect checksum."),
(T.AddSeconds(1), "SQL Server has encountered 3 occurrence(s) of I/O requests taking longer than 15 seconds to complete on file [D:\\Data\\AX.mdf]"),
(T.AddSeconds(2), "Login failed for user 'sa'. Reason: Password did not match. [CLIENT: 10.0.0.9]"),
(T.AddSeconds(3), "Error: 17310, Severity: 20, State: 1."),
]);
Assert.Equal(4, entries.Count);
Assert.Equal((824, 24), (entries[0].Error, entries[0].Severity));
Assert.Contains("checksum", entries[0].Message, StringComparison.Ordinal);
Assert.Equal(833, entries[1].Error);
Assert.Equal(18456, entries[2].Error);
Assert.Equal(17310, entries[3].Error);
}
[Theory]
[InlineData(825, 10, null, ErrorLogCategory.Corruption)]
[InlineData(833, 10, null, ErrorLogCategory.SlowIo)]
[InlineData(18456, 14, null, ErrorLogCategory.LoginFailed)]
[InlineData(35206, 16, null, ErrorLogCategory.AvailabilityGroup)]
[InlineData(null, null, "***Stack Dump being sent to C:\\…\\SQLDump0001.txt", ErrorLogCategory.StackDump)]
[InlineData(701, 17, null, ErrorLogCategory.Severe)]
[InlineData(5701, 10, "Changed database context to 'master'.", ErrorLogCategory.Other)]
public void Entries_are_categorized(int? error, int? severity, string? message, ErrorLogCategory expected) =>
Assert.Equal(expected, ErrorLogAnalysis.Categorize(new ErrorLogEntry(T, error, severity, message)));
[Fact]
public void Repetitions_are_summed_and_kept_for_a_day()
{
var first = ErrorLogAnalysis.Group(Enumerable.Range(0, 30).Select(i => new ErrorLogEntry(T.AddSeconds(i), 18456, 14, "Login failed for user 'x'.")));
var later = ErrorLogAnalysis.Group([new ErrorLogEntry(T.AddHours(2), 18456, 14, "Login failed for user 'y'.")]);
var login = Assert.Single(first);
Assert.Equal((30, T, T.AddSeconds(29)), (login.Count, login.FirstAt, login.LastAt));
var recent = Assert.Single(ErrorLogAnalysis.Recent(first, later, T.AddHours(2)));
Assert.Equal(31, recent.Count);
Assert.Contains("'y'", recent.Sample, StringComparison.Ordinal);
Assert.Single(ErrorLogAnalysis.Recent(first, later, T.AddHours(25))); // the first batch has expired
Assert.Equal(1, ErrorLogAnalysis.Recent(first, later, T.AddHours(25))[0].Count);
}
}

View file

@ -0,0 +1,129 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Data.SqlClient;
using SQLInsight.Collectors.Instance;
using SQLInsight.Core.Data;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
namespace SQLInsight.IntegrationTests;
/// <summary>The error log as the least-privilege agent sees it: ring buffer without ALTER TRACE, default trace with it.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class ErrorLogTests(SqlServerFixture sql)
{
private static async Task<ErrorLogSnapshot> UntilAsync(AgentSession session, ErrorLogCollector collector, ErrorLogSnapshot previous, Func<ErrorLogSnapshot, bool> done,
int seconds = 30)
{
var latest = previous;
// SQL Server writes the default trace in buffers: events can take a while to become readable.
for (var i = 0; i < seconds; i++)
{
await Task.Delay(1000);
latest = await session.CollectAsync(collector, latest with { Recent = latest.Recent });
if (done(latest))
{
break;
}
}
return latest;
}
[Fact]
public async Task Without_alter_trace_failed_logins_come_from_the_ring_buffer_with_the_reason()
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new ErrorLogCollector();
var first = await session.CollectAsync<ErrorLogSnapshot>(collector);
for (var i = 0; i < 3; i++)
{
var wrong = new SqlConnectionStringBuilder(sql.AgentConnectionString) { Password = "forkert-" + i, Pooling = false };
await using var connection = new SqlConnection(wrong.ConnectionString);
await Assert.ThrowsAsync<SqlException>(() => connection.OpenAsync());
}
var latest = await UntilAsync(session, collector, first, s => s.Recent.Any(g => g.Category == ErrorLogCategory.LoginFailed && g.Count >= 3));
Assert.Equal(ErrorLogSource.RingBuffer, first.Source);
Assert.Contains("ALTER TRACE", first.Coverage, StringComparison.Ordinal);
var logins = Assert.Single(latest.Recent, g => g.Category == ErrorLogCategory.LoginFailed);
Assert.True(logins.Count >= 3);
Assert.Contains("state 8", logins.Sample, StringComparison.Ordinal);
}
[Fact]
public async Task With_alter_trace_the_error_log_and_failed_logins_are_read_from_the_default_trace()
{
await sql.ExecuteAsSaAsync($"GRANT ALTER TRACE TO [{SqlServerFixture.AgentLogin}];");
try
{
await using var session = await AgentSession.OpenAsync(sql);
var collector = new ErrorLogCollector(loginFailureBurst: 3);
var first = await session.CollectAsync<ErrorLogSnapshot>(collector);
await Assert.ThrowsAsync<SqlException>(() => sql.ExecuteAsSaAsync("RAISERROR (N'SQLInsight test written to the error log', 20, 1) WITH LOG;"));
for (var i = 0; i < 4; i++)
{
var wrong = new SqlConnectionStringBuilder(sql.AgentConnectionString) { Password = "forkert-" + i, Pooling = false };
await using var connection = new SqlConnection(wrong.ConnectionString);
await Assert.ThrowsAsync<SqlException>(() => connection.OpenAsync());
}
var latest = await UntilAsync(session, collector, first,
s => s.Recent.Any(g => g.Error == 50000) && s.Recent.Any(g => g.Category == ErrorLogCategory.LoginFailed && g.Count >= 4), seconds: 120);
Assert.Equal(ErrorLogSource.DefaultTrace, latest.Source);
Assert.Contains("securityadmin", latest.Coverage, StringComparison.Ordinal);
var logged = Assert.Single(latest.Recent, g => g.Error == 50000);
Assert.Contains("SQLInsight test written to the error log", logged.Sample, StringComparison.Ordinal);
Assert.True(latest.Recent.Single(g => g.Category == ErrorLogCategory.LoginFailed).Count >= 4);
}
finally
{
await sql.ExecuteAsSaAsync($"REVOKE ALTER TRACE FROM [{SqlServerFixture.AgentLogin}];");
}
}
[Fact]
public async Task Page_and_api_show_events_with_the_sample_left_out_of_the_api()
{
var database = "SQLInsightLog_" + Guid.NewGuid().ToString("N")[..8];
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{database}];");
try
{
var repository = new InsightRepository(sql.DatabaseConnectionString(database));
await repository.MigrateAsync();
var now = DateTimeOffset.UtcNow;
var id = await repository.UpsertInstanceAsync("prod-sql01", new InstanceInfo
{
ServerName = "SQL01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
}, "SqlClient", false, now);
var groups = new[] { new ErrorLogGroup(ErrorLogCategory.Corruption, 824, 24, 2, now.AddMinutes(-4), now.AddMinutes(-1), "incorrect checksum in AX.mdf") };
await repository.SaveCollectedAsync(id, "error-log", new ErrorLogSnapshot
{
CapturedAt = now, Source = ErrorLogSource.DefaultTrace, ReadUntil = now, Groups = groups, Recent = groups,
});
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
var client = factory.CreateClient();
var page = await client.GetStringAsync("/instanser/prod-sql01/fejllog");
var api = await client.GetStringAsync("/api/v1/instances/prod-sql01/error-log");
Assert.Contains("Beskadigede sider", page, StringComparison.Ordinal);
Assert.Contains("incorrect checksum", page, StringComparison.Ordinal);
Assert.Contains("824", api, StringComparison.Ordinal);
Assert.Contains("[udeladt]", api, StringComparison.Ordinal);
Assert.DoesNotContain("incorrect checksum", api, StringComparison.Ordinal);
}
finally
{
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{database}];");
}
}
}

View file

@ -0,0 +1,56 @@
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Engine;
using SQLInsight.Rules.Instance;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class ErrorLogRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static ErrorLogSnapshot Log(ErrorLogSource source, params ErrorLogGroup[] groups) => new()
{
CapturedAt = Now, Source = source, ReadUntil = Now, Groups = groups, Recent = groups, LoginFailureBurst = 20,
Coverage = source == ErrorLogSource.ErrorLog ? null : "Giv agenten ALTER TRACE eller securityadmin",
};
private static ErrorLogGroup G(ErrorLogCategory category, int error, int severity, int count = 1) =>
new(category, error, severity, count, Now.AddMinutes(-3), Now.AddMinutes(-1), "Error text with login 'anne'");
[Theory]
[InlineData(824, Severity.Critical)]
[InlineData(825, Severity.High)]
public void Corruption_is_critical_and_read_retries_high(int error, Severity expected)
{
var finding = Assert.Single(Run(new CorruptionErrorRule(), With(Log(ErrorLogSource.DefaultTrace, G(ErrorLogCategory.Corruption, error, 24)))).Findings);
Assert.Equal(expected, finding.Severity);
Assert.True(finding.Evidence.Single(e => e.Name == "Eksempel").Sensitive);
}
[Fact]
public void Slow_io_and_stack_dumps_need_the_text_of_the_log()
{
var result = Run(new SlowIoErrorRule(), With(Log(ErrorLogSource.RingBuffer)));
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
Assert.Contains("ALTER TRACE", result.Reason, StringComparison.Ordinal);
Assert.Equal(RuleOutcome.NotMeasurable, Run(new SlowIoErrorRule(), With(Log(ErrorLogSource.DefaultTrace))).Outcome);
Assert.Equal(RuleOutcome.Findings, Run(new SlowIoErrorRule(), With(Log(ErrorLogSource.ErrorLog, G(ErrorLogCategory.SlowIo, 833, 10)))).Outcome);
Assert.Equal(RuleOutcome.NotMeasurable, Run(new StackDumpRule(), With(Log(ErrorLogSource.RingBuffer))).Outcome);
}
[Theory]
[InlineData(19, RuleOutcome.Passed)]
[InlineData(20, RuleOutcome.Findings)]
public void Login_failures_are_a_finding_from_the_burst_size(int count, RuleOutcome expected) =>
Assert.Equal(expected, Run(new LoginFailureBurstRule(), With(Log(ErrorLogSource.RingBuffer, G(ErrorLogCategory.LoginFailed, 18456, 14, count)))).Outcome);
[Theory]
[InlineData(17, Severity.Medium)]
[InlineData(21, Severity.High)]
public void Severe_errors_by_severity(int severity, Severity expected) =>
Assert.Equal(expected, Assert.Single(Run(new SevereErrorRule(), With(Log(ErrorLogSource.RingBuffer, G(ErrorLogCategory.Severe, 701, severity)))).Findings).Severity);
}

View file

@ -18,6 +18,6 @@ public class RuleCatalogTests
[Fact]
public void Rule_count()
{
Assert.Equal(66, BuiltInRules.Create().Count);
Assert.Equal(72, BuiltInRules.Create().Count);
}
}