sqlinsight-pro/src/SQLInsight.Core/Data/QueryStats.cs
Søren Eggert Lundsteen Olsen bb3b38e77f Top SQL: collect what the heaviest statements use every 5 minutes
New collector query-stats reads sys.dm_exec_query_stats for the statements
that ran since the previous collection, grouped by query_hash (SQL Server
2008+) or sql_handle + offsets (2005, via legacy ODBC; literals only). Only
the 50 heaviest by CPU, duration, reads and executions, text cut to 4000
characters, no plan XML. The database comes from the plan's dbid attribute,
because ad hoc batches (as Dynamics AX sends) have none in sql_text.

QueryStatsDelta diffs against the previous collection: a statement seen
before with the same plan gets the difference, a plan compiled since then
counts in full, a recompiled or evicted plan or counters that went
backwards start over (never negative), a restart gives no delta. Up to 2000
statements are remembered between collections.

Repository migration 0006: insight.QueryStatSamples (deltas, top 100 per
collection) and insight.QueryTexts (text once per statement), kept for
Retention:RawDays. The text is customer data: never stored as a JSON
snapshot and never part of hub submissions. Window queries for metrics,
waits (raw and hourly), file I/O, blocking, deadlocks, findings active in a
window and top SQL by CPU/duration/reads/executions.

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

156 lines
6.7 KiB
C#

namespace SQLInsight.Core.Data;
/// <summary>
/// Cumulative statistics for the statements that ran since the previous collection (<c>sys.dm_exec_query_stats</c>,
/// grouped per statement), plus statements remembered from earlier collections so their totals can still be diffed.
/// </summary>
public sealed record QueryStatsSnapshot
{
/// <summary>Most statements remembered between collections; the least recently executed are forgotten first.</summary>
public const int MaxRemembered = 2000;
public required DateTimeOffset CapturedAt { get; init; }
/// <summary>The server's own clock (GETDATE()) at capture: <c>creation_time</c> and <c>last_execution_time</c> are in it.</summary>
public required DateTime ServerTime { get; init; }
public required DateTime SqlServerStartTime { get; init; }
public required IReadOnlyList<QueryStat> Queries { get; init; }
}
/// <summary>
/// One statement's totals since its first plan in cache was compiled. Times are in microseconds, as the DMV gives them.
/// </summary>
public sealed record QueryStat
{
/// <summary>query_hash as hex (SQL Server 2008+), or sql_handle:start:end on SQL Server 2005.</summary>
public required string Key { get; init; }
public string? DatabaseName { get; init; }
/// <summary>The statement, cut to <see cref="MaxTextLength"/> characters. Customer data: never sent to the hub.</summary>
public string? Text { get; init; }
public required long Executions { get; init; }
public required long WorkerTimeUs { get; init; }
public required long ElapsedTimeUs { get; init; }
public required long LogicalReads { get; init; }
public required long PhysicalReads { get; init; }
public required long LogicalWrites { get; init; }
/// <summary>The oldest cached plan's creation_time (server clock). A newer value means the plan was recompiled.</summary>
public required DateTime CreatedAt { get; init; }
/// <summary>last_execution_time (server clock).</summary>
public required DateTime LastExecutionAt { get; init; }
public const int MaxTextLength = 4000;
}
/// <summary>
/// What each statement used between two collections.
/// </summary>
public sealed record QueryStatsDelta
{
public required DateTimeOffset From { get; init; }
public required DateTimeOffset To { get; init; }
public required IReadOnlyList<QueryStatDelta> Queries { get; init; }
/// <summary>
/// The change per statement. A statement only gets a delta when its starting point is known: it was seen before
/// with the same plan, or its plan was compiled after the previous collection (then all of its totals are new).
/// A plan recompiled or evicted in between, or counters that went backwards, start over: no negative numbers.
/// After an instance restart there is no delta at all.
/// </summary>
public static QueryStatsDelta? Compute(QueryStatsSnapshot? previous, QueryStatsSnapshot current)
{
ArgumentNullException.ThrowIfNull(current);
if (previous is null || previous.SqlServerStartTime != current.SqlServerStartTime || current.CapturedAt <= previous.CapturedAt)
{
return null;
}
var before = previous.Queries.ToDictionary(q => q.Key, StringComparer.Ordinal);
var changes = new List<QueryStatDelta>();
foreach (var now in current.Queries)
{
// Remembered from an earlier collection but not run since: nothing new.
if (now.LastExecutionAt < previous.ServerTime && before.ContainsKey(now.Key))
{
continue;
}
var newSinceLast = now.CreatedAt >= previous.ServerTime;
QueryStatDelta? delta;
if (before.TryGetValue(now.Key, out var then) && then.CreatedAt == now.CreatedAt && !WentBackwards(then, now))
{
delta = Difference(now, then);
}
else
{
delta = newSinceLast ? Difference(now, null) : null;
}
if (delta is { Executions: > 0 } or { WorkerTimeUs: > 0 })
{
changes.Add(delta);
}
}
return new QueryStatsDelta
{
From = previous.CapturedAt,
To = current.CapturedAt,
Queries = changes.OrderByDescending(q => q.WorkerTimeUs).ThenByDescending(q => q.ElapsedTimeUs).ToList(),
};
}
/// <summary>
/// The snapshot to keep for the next collection: this collection's statements plus the remembered ones not seen
/// now, at most <see cref="QueryStatsSnapshot.MaxRemembered"/>. After a restart the old ones are dropped.
/// </summary>
public static IReadOnlyList<QueryStat> Remember(QueryStatsSnapshot? previous, IReadOnlyList<QueryStat> current, DateTime sqlServerStartTime)
{
ArgumentNullException.ThrowIfNull(current);
var keys = current.Select(q => q.Key).ToHashSet(StringComparer.Ordinal);
var older = previous is null || previous.SqlServerStartTime != sqlServerStartTime
? []
: previous.Queries.Where(q => !keys.Contains(q.Key));
return current.Concat(older.OrderByDescending(q => q.LastExecutionAt))
.Take(QueryStatsSnapshot.MaxRemembered)
.ToList();
}
private static bool WentBackwards(QueryStat then, QueryStat now) =>
now.Executions < then.Executions || now.WorkerTimeUs < then.WorkerTimeUs || now.ElapsedTimeUs < then.ElapsedTimeUs
|| now.LogicalReads < then.LogicalReads || now.PhysicalReads < then.PhysicalReads || now.LogicalWrites < then.LogicalWrites;
private static QueryStatDelta Difference(QueryStat now, QueryStat? then) => new()
{
Key = now.Key,
DatabaseName = now.DatabaseName,
Text = now.Text,
Executions = now.Executions - (then?.Executions ?? 0),
WorkerTimeUs = now.WorkerTimeUs - (then?.WorkerTimeUs ?? 0),
ElapsedTimeUs = now.ElapsedTimeUs - (then?.ElapsedTimeUs ?? 0),
LogicalReads = now.LogicalReads - (then?.LogicalReads ?? 0),
PhysicalReads = now.PhysicalReads - (then?.PhysicalReads ?? 0),
LogicalWrites = now.LogicalWrites - (then?.LogicalWrites ?? 0),
};
}
public sealed record QueryStatDelta
{
public required string Key { get; init; }
public string? DatabaseName { get; init; }
/// <summary>The statement. Customer data: shown only in the local web UI, never sent to the hub.</summary>
public string? Text { get; init; }
public required long Executions { get; init; }
public required long WorkerTimeUs { get; init; }
public required long ElapsedTimeUs { get; init; }
public required long LogicalReads { get; init; }
public required long PhysicalReads { get; init; }
public required long LogicalWrites { get; init; }
}