Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Baselines per hour of the week (with neighbouring hours) from four weeks of hourly aggregates, computed in C# (independent of DATEFIRST) and refreshed by the agent each hour. SQLI-ANOM-001 compares current load with what is normal at this time: CPU far above normal, batch requests far above or collapsed (application down?), page life expectancy collapse. Not measurable until enough history exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
272 lines
16 KiB
C#
272 lines
16 KiB
C#
using System.Text.Json;
|
|
using Dapper;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Rules.Engine;
|
|
|
|
namespace SQLInsight.Repository;
|
|
|
|
public sealed record InstanceOverview
|
|
{
|
|
public int InstanceId { get; init; }
|
|
public string Name { get; init; } = string.Empty;
|
|
public string? ServerName { get; init; }
|
|
public string? ProductVersion { get; init; }
|
|
public string? ProductLevel { get; init; }
|
|
public string? ProductUpdateLevel { get; init; }
|
|
public string? Edition { get; init; }
|
|
public string? ConnectionProvider { get; init; }
|
|
public bool UsedLegacyFallback { get; init; }
|
|
public DateTime? LastSeenAt { get; init; }
|
|
public DateTime? LastErrorAt { get; init; }
|
|
public string? LastError { get; init; }
|
|
public int Critical { get; init; }
|
|
public int High { get; init; }
|
|
public int Medium { get; init; }
|
|
public int Low { get; init; }
|
|
public int Info { get; init; }
|
|
public int NotMeasurable { get; init; }
|
|
|
|
/// <summary>The most recent contact failed.</summary>
|
|
public bool IsFailing => LastErrorAt is { } e && (LastSeenAt is null || e > LastSeenAt);
|
|
|
|
public Severity? WorstSeverity =>
|
|
Critical > 0 ? Severity.Critical : High > 0 ? Severity.High : Medium > 0 ? Severity.Medium : Low > 0 ? Severity.Low : Info > 0 ? Severity.Info : null;
|
|
}
|
|
|
|
public sealed record StoredFinding
|
|
{
|
|
public long FindingId { get; init; }
|
|
public int InstanceId { get; init; }
|
|
public string InstanceName { get; init; } = string.Empty;
|
|
public string RuleId { get; init; } = string.Empty;
|
|
public string Subject { get; init; } = string.Empty;
|
|
public Severity Severity { get; init; }
|
|
public string Title { get; init; } = string.Empty;
|
|
public string Explanation { get; init; } = string.Empty;
|
|
public string Recommendation { get; init; } = string.Empty;
|
|
public IReadOnlyList<Evidence> Evidence { get; init; } = [];
|
|
public IReadOnlyList<Reference> References { get; init; } = [];
|
|
public string? FixScript { get; init; }
|
|
public DateTime FirstSeenAt { get; init; }
|
|
public DateTime LastSeenAt { get; init; }
|
|
public DateTime? ResolvedAt { get; init; }
|
|
}
|
|
|
|
public sealed record StoredRuleState(string RuleId, string RuleTitle, RuleOutcome Outcome, string? Reason, DateTime EvaluatedAt);
|
|
|
|
public sealed record MetricPoint(DateTime At, double Value);
|
|
|
|
public sealed record WaitTotal(string WaitType, long WaitingTasks, long WaitTimeMs, long SignalWaitTimeMs)
|
|
{
|
|
public WaitCategory Category => WaitCatalog.Categorize(WaitType);
|
|
}
|
|
|
|
public sealed record FileLatency(string DatabaseName, int FileId, string FileType, long Reads, long ReadStallMs, long Writes, long WriteStallMs)
|
|
{
|
|
public double? AverageReadMs => Reads == 0 ? null : (double)ReadStallMs / Reads;
|
|
public double? AverageWriteMs => Writes == 0 ? null : (double)WriteStallMs / Writes;
|
|
}
|
|
|
|
public sealed record StoredDeadlock(long DeadlockId, DateTime OccurredAt, IReadOnlyList<DeadlockProcess> Processes, string GraphXml);
|
|
|
|
public sealed record StoredBlocking(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, IReadOnlyList<SessionInfo> Sessions);
|
|
|
|
public sealed partial class InsightRepository
|
|
{
|
|
public async Task<IReadOnlyList<InstanceOverview>> GetInstancesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<InstanceOverview>("""
|
|
SELECT i.InstanceId, i.Name, i.ServerName, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.ConnectionProvider,
|
|
i.UsedLegacyFallback, i.LastSeenAt, i.LastErrorAt, i.LastError,
|
|
ISNULL(f.Critical, 0) AS Critical, ISNULL(f.High, 0) AS High, ISNULL(f.Medium, 0) AS Medium, ISNULL(f.Low, 0) AS Low, ISNULL(f.Info, 0) AS Info,
|
|
ISNULL(r.NotMeasurable, 0) AS NotMeasurable
|
|
FROM insight.Instances i
|
|
OUTER APPLY (SELECT SUM(CASE WHEN Severity = 4 THEN 1 ELSE 0 END) AS Critical, SUM(CASE WHEN Severity = 3 THEN 1 ELSE 0 END) AS High,
|
|
SUM(CASE WHEN Severity = 2 THEN 1 ELSE 0 END) AS Medium, SUM(CASE WHEN Severity = 1 THEN 1 ELSE 0 END) AS Low,
|
|
SUM(CASE WHEN Severity = 0 THEN 1 ELSE 0 END) AS Info
|
|
FROM insight.Findings WHERE InstanceId = i.InstanceId AND ResolvedAt IS NULL) f
|
|
OUTER APPLY (SELECT COUNT(*) AS NotMeasurable FROM insight.RuleStates WHERE InstanceId = i.InstanceId AND Outcome IN (2, 3)) r
|
|
ORDER BY i.Name;
|
|
""").ConfigureAwait(false);
|
|
return rows.ToList();
|
|
}
|
|
|
|
public async Task<InstanceOverview?> GetInstanceAsync(string name, CancellationToken cancellationToken = default) =>
|
|
(await GetInstancesAsync(cancellationToken).ConfigureAwait(false)).FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private const string FindingColumns = """
|
|
f.FindingId, f.InstanceId, i.Name AS InstanceName, f.RuleId, f.Subject, f.Severity, f.Title, f.Explanation, f.Recommendation,
|
|
f.EvidenceJson, f.ReferencesJson, f.FixScript, f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt
|
|
""";
|
|
|
|
public async Task<IReadOnlyList<StoredFinding>> GetActiveFindingsAsync(int? instanceId = null, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<FindingRow>($"""
|
|
SELECT {FindingColumns}
|
|
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
|
|
WHERE f.ResolvedAt IS NULL AND (@instanceId IS NULL OR f.InstanceId = @instanceId)
|
|
ORDER BY f.Severity DESC, f.FirstSeenAt;
|
|
""", new { instanceId }).ConfigureAwait(false);
|
|
return rows.Select(Map).ToList();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<StoredFinding>> GetRecentlyResolvedAsync(int instanceId, int days, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<FindingRow>($"""
|
|
SELECT TOP (50) {FindingColumns}
|
|
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
|
|
WHERE f.InstanceId = @instanceId AND f.ResolvedAt >= DATEADD(day, -@days, SYSUTCDATETIME())
|
|
ORDER BY f.ResolvedAt DESC;
|
|
""", new { instanceId, days }).ConfigureAwait(false);
|
|
return rows.Select(Map).ToList();
|
|
}
|
|
|
|
public async Task<StoredFinding?> GetFindingAsync(long findingId, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var row = await connection.QuerySingleOrDefaultAsync<FindingRow>($"""
|
|
SELECT {FindingColumns}
|
|
FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
|
|
WHERE f.FindingId = @findingId;
|
|
""", new { findingId }).ConfigureAwait(false);
|
|
return row is null ? null : Map(row);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<StoredRuleState>> GetRuleStatesAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<(string RuleId, string RuleTitle, byte Outcome, string? Reason, DateTime EvaluatedAt)>(
|
|
"SELECT RuleId, RuleTitle, Outcome, Reason, EvaluatedAt FROM insight.RuleStates WHERE InstanceId = @instanceId ORDER BY RuleId;",
|
|
new { instanceId }).ConfigureAwait(false);
|
|
return rows.Select(r => new StoredRuleState(r.RuleId, r.RuleTitle, (RuleOutcome)r.Outcome, r.Reason, r.EvaluatedAt)).ToList();
|
|
}
|
|
|
|
/// <summary>A metric over time: raw samples within raw retention, hourly averages beyond.</summary>
|
|
public async Task<IReadOnlyList<MetricPoint>> GetMetricAsync(int instanceId, string metric, DateTimeOffset from, int rawDays = 14, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var useHourly = from.UtcDateTime < DateTime.UtcNow.AddDays(-rawDays) || DateTime.UtcNow - from.UtcDateTime > TimeSpan.FromDays(2);
|
|
var sql = useHourly
|
|
? "SELECT [Hour] AS At, AvgValue AS Value FROM insight.MetricHourly WHERE InstanceId = @instanceId AND Metric = @metric AND [Hour] >= @from ORDER BY [Hour];"
|
|
: "SELECT SampledAt AS At, Value FROM insight.MetricSamples WHERE InstanceId = @instanceId AND Metric = @metric AND SampledAt >= @from ORDER BY SampledAt;";
|
|
var rows = await connection.QueryAsync<MetricPoint>(sql, new { instanceId, metric, from = from.UtcDateTime }).ConfigureAwait(false);
|
|
return rows.ToList();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WaitTotal>> GetTopWaitsAsync(int instanceId, DateTimeOffset from, int top = 10, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<WaitTotal>("""
|
|
SELECT TOP (@top) WaitType, SUM(WaitingTasks) AS WaitingTasks, SUM(WaitTimeMs) AS WaitTimeMs, SUM(SignalWaitTimeMs) AS SignalWaitTimeMs
|
|
FROM insight.WaitSamples WHERE InstanceId = @instanceId AND SampledAt >= @from
|
|
GROUP BY WaitType ORDER BY SUM(WaitTimeMs) DESC;
|
|
""", new { instanceId, from = from.UtcDateTime, top }).ConfigureAwait(false);
|
|
return rows.ToList();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<FileLatency>> GetFileLatencyAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<FileLatency>("""
|
|
SELECT DatabaseName, FileId, MAX(FileType) AS FileType, SUM(Reads) AS Reads, SUM(ReadStallMs) AS ReadStallMs, SUM(Writes) AS Writes, SUM(WriteStallMs) AS WriteStallMs
|
|
FROM insight.FileIoSamples WHERE InstanceId = @instanceId AND SampledAt >= @from
|
|
GROUP BY DatabaseName, FileId
|
|
ORDER BY SUM(ReadStallMs) + SUM(WriteStallMs) DESC;
|
|
""", new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false);
|
|
return rows.ToList();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<StoredDeadlock>> GetDeadlocksAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<(long DeadlockId, DateTime OccurredAt, string ProcessesJson, string GraphXml)>("""
|
|
SELECT TOP (200) DeadlockId, OccurredAt, ProcessesJson, GraphXml FROM insight.Deadlocks
|
|
WHERE InstanceId = @instanceId AND OccurredAt >= @from ORDER BY OccurredAt DESC;
|
|
""", new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false);
|
|
return rows.Select(r => new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize<List<DeadlockProcess>>(r.ProcessesJson, Json) ?? [], r.GraphXml)).ToList();
|
|
}
|
|
|
|
public async Task<StoredDeadlock?> GetDeadlockAsync(long deadlockId, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var row = await connection.QuerySingleOrDefaultAsync<(long DeadlockId, DateTime OccurredAt, string ProcessesJson, string GraphXml)?>(
|
|
"SELECT DeadlockId, OccurredAt, ProcessesJson, GraphXml FROM insight.Deadlocks WHERE DeadlockId = @deadlockId;", new { deadlockId }).ConfigureAwait(false);
|
|
return row is { } r ? new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize<List<DeadlockProcess>>(r.ProcessesJson, Json) ?? [], r.GraphXml) : null;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<StoredBlocking>> GetBlockingAsync(int instanceId, DateTimeOffset from, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<(DateTime SampledAt, int HeadSessionId, int BlockedCount, long MaxWaitMs, string SessionsJson)>("""
|
|
SELECT TOP (200) SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson FROM insight.BlockingEvents
|
|
WHERE InstanceId = @instanceId AND SampledAt >= @from ORDER BY SampledAt DESC;
|
|
""", new { instanceId, from = from.UtcDateTime }).ConfigureAwait(false);
|
|
return rows.Select(r => new StoredBlocking(r.SampledAt, r.HeadSessionId, r.BlockedCount, r.MaxWaitMs,
|
|
JsonSerializer.Deserialize<List<SessionInfo>>(r.SessionsJson, Json) ?? [])).ToList();
|
|
}
|
|
|
|
public async Task<T?> GetSnapshotAsync<T>(int instanceId, string kind, CancellationToken cancellationToken = default)
|
|
where T : class
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var json = await connection.QuerySingleOrDefaultAsync<string>(
|
|
"SELECT Json FROM insight.Snapshots WHERE InstanceId = @instanceId AND Kind = @kind;", new { instanceId, kind }).ConfigureAwait(false);
|
|
return json is null ? null : JsonSerializer.Deserialize<T>(json, Json);
|
|
}
|
|
|
|
private static StoredFinding Map(FindingRow r) => new()
|
|
{
|
|
FindingId = r.FindingId,
|
|
InstanceId = r.InstanceId,
|
|
InstanceName = r.InstanceName,
|
|
RuleId = r.RuleId,
|
|
Subject = r.Subject,
|
|
Severity = (Severity)r.Severity,
|
|
Title = r.Title,
|
|
Explanation = r.Explanation,
|
|
Recommendation = r.Recommendation,
|
|
Evidence = JsonSerializer.Deserialize<List<Evidence>>(r.EvidenceJson, Json) ?? [],
|
|
References = JsonSerializer.Deserialize<List<Reference>>(r.ReferencesJson, Json) ?? [],
|
|
FixScript = r.FixScript,
|
|
FirstSeenAt = r.FirstSeenAt,
|
|
LastSeenAt = r.LastSeenAt,
|
|
ResolvedAt = r.ResolvedAt,
|
|
};
|
|
|
|
private sealed record FindingRow
|
|
{
|
|
public long FindingId { get; init; }
|
|
public int InstanceId { get; init; }
|
|
public string InstanceName { get; init; } = string.Empty;
|
|
public string RuleId { get; init; } = string.Empty;
|
|
public string Subject { get; init; } = string.Empty;
|
|
public byte Severity { get; init; }
|
|
public string Title { get; init; } = string.Empty;
|
|
public string Explanation { get; init; } = string.Empty;
|
|
public string Recommendation { get; init; } = string.Empty;
|
|
public string EvidenceJson { get; init; } = "[]";
|
|
public string ReferencesJson { get; init; } = "[]";
|
|
public string? FixScript { get; init; }
|
|
public DateTime FirstSeenAt { get; init; }
|
|
public DateTime LastSeenAt { get; init; }
|
|
public DateTime? ResolvedAt { get; init; }
|
|
}
|
|
}
|
|
|
|
public sealed partial class InsightRepository
|
|
{
|
|
/// <summary>Hourly averages for the given metrics over the last <paramref name="days"/> days, for baselines.</summary>
|
|
public async Task<IReadOnlyList<HourlyValue>> GetHourlyHistoryAsync(int instanceId, IReadOnlyCollection<string> metrics, DateTimeOffset now, int days = 28, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
|
|
var rows = await connection.QueryAsync<(string Metric, DateTime Hour, double Value)>("""
|
|
SELECT Metric, [Hour], AvgValue FROM insight.MetricHourly
|
|
WHERE InstanceId = @instanceId AND [Hour] >= @from AND Metric IN (SELECT value FROM OPENJSON(@metrics));
|
|
""", new { instanceId, from = now.UtcDateTime.AddDays(-days), metrics = JsonSerializer.Serialize(metrics, Json) }).ConfigureAwait(false);
|
|
return rows.Select(r => new HourlyValue(r.Metric, DateTime.SpecifyKind(r.Hour, DateTimeKind.Utc), r.Value)).ToList();
|
|
}
|
|
}
|