diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs
index 7173590..c6c6574 100644
--- a/src/SQLInsight.Collectors/BuiltInCollectors.cs
+++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs
@@ -18,6 +18,7 @@ public static class BuiltInCollectors
new PlanCacheCollector(),
new WaitStatsCollector(),
new FileIoCollector(),
+ new QueryStatsCollector(),
new PerformanceCounterCollector(),
new CpuCollector(),
new BlockingCollector(),
diff --git a/src/SQLInsight.Collectors/Performance/QueryStatsCollector.cs b/src/SQLInsight.Collectors/Performance/QueryStatsCollector.cs
new file mode 100644
index 0000000..fb31a70
--- /dev/null
+++ b/src/SQLInsight.Collectors/Performance/QueryStatsCollector.cs
@@ -0,0 +1,131 @@
+using System.Globalization;
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Instances;
+
+namespace SQLInsight.Collectors.Performance;
+
+///
+/// Top SQL: cumulative statistics from sys.dm_exec_query_stats for the statements that ran since the previous
+/// collection, so the time window analysis can show which statements used the CPU, time and reads. Kept light: only
+/// the heaviest statements by CPU, duration, reads and executions, their text cut to
+/// characters, and no plan XML.
+///
+public sealed class QueryStatsCollector : Collector>
+{
+ public const int TopPerMeasure = 50;
+
+ ///
+ /// Statements are grouped by query_hash, so the same statement with different literals counts as one. The database
+ /// is the batch's own (procedures) or else the one it ran in (plan attribute dbid): ad hoc batches, as AX sends, have none.
+ ///
+ internal const string Sql2008 = """
+ WITH s AS (
+ SELECT qs.query_hash AS k,
+ SUM(qs.execution_count) AS execs, SUM(qs.total_worker_time) AS cpu, SUM(qs.total_elapsed_time) AS elapsed,
+ SUM(qs.total_logical_reads) AS lreads, SUM(qs.total_physical_reads) AS preads, SUM(qs.total_logical_writes) AS writes,
+ MIN(qs.creation_time) AS created, MAX(qs.last_execution_time) AS last_exec
+ FROM sys.dm_exec_query_stats qs
+ GROUP BY qs.query_hash
+ HAVING MAX(qs.last_execution_time) >= {since}),
+ t AS (
+ SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY cpu DESC) a
+ UNION SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY elapsed DESC) b
+ UNION SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY lreads DESC) c
+ UNION SELECT k FROM (SELECT TOP ({top}) k FROM s ORDER BY execs DESC) d)
+ SELECT CONVERT(varchar(20), s.k, 1), s.execs, s.cpu, s.elapsed, s.lreads, s.preads, s.writes, s.created, s.last_exec,
+ DB_NAME(COALESCE(st.dbid, pa.dbid)),
+ CAST(SUBSTRING(st.text, r.statement_start_offset / 2 + 1,
+ CASE WHEN r.statement_end_offset = -1 THEN {length}
+ ELSE (r.statement_end_offset - r.statement_start_offset) / 2 + 1 END) AS nvarchar({length})),
+ GETDATE()
+ FROM s JOIN t ON t.k = s.k
+ OUTER APPLY (SELECT TOP (1) q.sql_handle, q.plan_handle, q.statement_start_offset, q.statement_end_offset
+ FROM sys.dm_exec_query_stats q WHERE q.query_hash = s.k ORDER BY q.last_execution_time DESC) r
+ OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) st
+ OUTER APPLY (SELECT CAST(a.value AS int) AS dbid FROM sys.dm_exec_plan_attributes(r.plan_handle) a WHERE a.attribute = N'dbid') pa
+ """;
+
+ /// SQL Server 2005 has no query_hash: a statement is its batch (sql_handle) and its offsets in it.
+ internal const string Sql2005 = """
+ WITH s AS (
+ SELECT qs.sql_handle AS h, qs.statement_start_offset AS so, qs.statement_end_offset AS eo,
+ SUM(qs.execution_count) AS execs, SUM(qs.total_worker_time) AS cpu, SUM(qs.total_elapsed_time) AS elapsed,
+ SUM(qs.total_logical_reads) AS lreads, SUM(qs.total_physical_reads) AS preads, SUM(qs.total_logical_writes) AS writes,
+ MIN(qs.creation_time) AS created, MAX(qs.last_execution_time) AS last_exec
+ FROM sys.dm_exec_query_stats qs
+ GROUP BY qs.sql_handle, qs.statement_start_offset, qs.statement_end_offset
+ HAVING MAX(qs.last_execution_time) >= {since}),
+ t AS (
+ SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY cpu DESC) a
+ UNION SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY elapsed DESC) b
+ UNION SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY lreads DESC) c
+ UNION SELECT h, so, eo FROM (SELECT TOP ({top}) h, so, eo FROM s ORDER BY execs DESC) d)
+ SELECT sys.fn_varbintohexstr(s.h) + ':' + CAST(s.so AS varchar(12)) + ':' + CAST(s.eo AS varchar(12)),
+ s.execs, s.cpu, s.elapsed, s.lreads, s.preads, s.writes, s.created, s.last_exec,
+ DB_NAME(COALESCE(st.dbid, pa.dbid)),
+ CAST(SUBSTRING(st.text, s.so / 2 + 1, CASE WHEN s.eo = -1 THEN {length} ELSE (s.eo - s.so) / 2 + 1 END) AS nvarchar({length})),
+ GETDATE()
+ FROM s JOIN t ON t.h = s.h AND t.so = s.so AND t.eo = s.eo
+ OUTER APPLY sys.dm_exec_sql_text(s.h) st
+ OUTER APPLY (SELECT TOP (1) q.plan_handle FROM sys.dm_exec_query_stats q
+ WHERE q.sql_handle = s.h AND q.statement_start_offset = s.so AND q.statement_end_offset = s.eo) r
+ OUTER APPLY (SELECT CAST(a.value AS int) AS dbid FROM sys.dm_exec_plan_attributes(r.plan_handle) a WHERE a.attribute = N'dbid') pa
+ """;
+
+ public override string Name => "query-stats";
+ public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
+ public override IReadOnlyCollection RequiredCapabilities => [Capability.DynamicManagementViews];
+
+ protected override async Task> CollectAsync(
+ CollectorContext context, CumulativeSample? previous)
+ {
+ var version = context.Instance.Version;
+ var ct = context.CancellationToken;
+ var startTime = await Db.StartTimeAsync(context.Connection, version, ct).ConfigureAwait(false);
+ var sql = SqlFor(version, previous?.Snapshot is { } last && last.SqlServerStartTime == startTime ? last.ServerTime : null);
+
+ DateTime? serverTime = null;
+ var rows = await Db.QueryAsync(context.Connection, sql, r =>
+ {
+ serverTime ??= r.DateTime(11);
+ var text = r.NullableString(10);
+ return new QueryStat
+ {
+ Key = r.String(0),
+ Executions = r.Long(1),
+ WorkerTimeUs = r.Long(2),
+ ElapsedTimeUs = r.Long(3),
+ LogicalReads = r.Long(4),
+ PhysicalReads = r.Long(5),
+ LogicalWrites = r.Long(6),
+ CreatedAt = r.DateTime(7),
+ LastExecutionAt = r.DateTime(8),
+ DatabaseName = r.NullableString(9),
+ Text = text is { Length: > QueryStat.MaxTextLength } ? text[..QueryStat.MaxTextLength] : text,
+ };
+ }, ct).ConfigureAwait(false);
+
+ // No statement ran since the previous collection: the server clock still has to move forward.
+ serverTime ??= (await Db.QueryAsync(context.Connection, "SELECT GETDATE()", r => r.DateTime(0), ct).ConfigureAwait(false))[0];
+
+ var fetched = new QueryStatsSnapshot { CapturedAt = context.Now, ServerTime = serverTime.Value, SqlServerStartTime = startTime, Queries = rows };
+ var delta = QueryStatsDelta.Compute(previous?.Snapshot, fetched);
+ var kept = fetched with { Queries = QueryStatsDelta.Remember(previous?.Snapshot, rows, startTime) };
+ return new(kept, delta);
+ }
+
+ ///
+ /// The query for the version. is the server clock at the previous collection; the first
+ /// time, the last 15 minutes. A literal, not a parameter: ODBC (SQL Server 2005) only knows positional parameters.
+ ///
+ internal static string SqlFor(SqlServerVersion version, DateTime? since)
+ {
+ var sinceSql = since is { } value
+ ? "CONVERT(datetime, '" + value.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture) + "', 126)"
+ : "DATEADD(minute, -15, GETDATE())";
+ return (version.Major >= 10 ? Sql2008 : Sql2005)
+ .Replace("{since}", sinceSql, StringComparison.Ordinal)
+ .Replace("{top}", TopPerMeasure.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)
+ .Replace("{length}", QueryStat.MaxTextLength.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal);
+ }
+}
diff --git a/src/SQLInsight.Core/Data/QueryStats.cs b/src/SQLInsight.Core/Data/QueryStats.cs
new file mode 100644
index 0000000..d81a946
--- /dev/null
+++ b/src/SQLInsight.Core/Data/QueryStats.cs
@@ -0,0 +1,156 @@
+namespace SQLInsight.Core.Data;
+
+///
+/// Cumulative statistics for the statements that ran since the previous collection (sys.dm_exec_query_stats,
+/// grouped per statement), plus statements remembered from earlier collections so their totals can still be diffed.
+///
+public sealed record QueryStatsSnapshot
+{
+ /// Most statements remembered between collections; the least recently executed are forgotten first.
+ public const int MaxRemembered = 2000;
+
+ public required DateTimeOffset CapturedAt { get; init; }
+
+ /// The server's own clock (GETDATE()) at capture: creation_time and last_execution_time are in it.
+ public required DateTime ServerTime { get; init; }
+
+ public required DateTime SqlServerStartTime { get; init; }
+ public required IReadOnlyList Queries { get; init; }
+}
+
+///
+/// One statement's totals since its first plan in cache was compiled. Times are in microseconds, as the DMV gives them.
+///
+public sealed record QueryStat
+{
+ /// query_hash as hex (SQL Server 2008+), or sql_handle:start:end on SQL Server 2005.
+ public required string Key { get; init; }
+
+ public string? DatabaseName { get; init; }
+
+ /// The statement, cut to characters. Customer data: never sent to the hub.
+ 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; }
+
+ /// The oldest cached plan's creation_time (server clock). A newer value means the plan was recompiled.
+ public required DateTime CreatedAt { get; init; }
+
+ /// last_execution_time (server clock).
+ public required DateTime LastExecutionAt { get; init; }
+
+ public const int MaxTextLength = 4000;
+}
+
+///
+/// What each statement used between two collections.
+///
+public sealed record QueryStatsDelta
+{
+ public required DateTimeOffset From { get; init; }
+ public required DateTimeOffset To { get; init; }
+ public required IReadOnlyList Queries { get; init; }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ 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(),
+ };
+ }
+
+ ///
+ /// The snapshot to keep for the next collection: this collection's statements plus the remembered ones not seen
+ /// now, at most . After a restart the old ones are dropped.
+ ///
+ public static IReadOnlyList Remember(QueryStatsSnapshot? previous, IReadOnlyList 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; }
+
+ /// The statement. Customer data: shown only in the local web UI, never sent to the hub.
+ 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; }
+}
diff --git a/src/SQLInsight.Repository/InsightRepository.Analysis.cs b/src/SQLInsight.Repository/InsightRepository.Analysis.cs
new file mode 100644
index 0000000..9e1459f
--- /dev/null
+++ b/src/SQLInsight.Repository/InsightRepository.Analysis.cs
@@ -0,0 +1,189 @@
+using System.Text.Json;
+using Dapper;
+using Microsoft.Data.SqlClient;
+using SQLInsight.Core.Data;
+
+namespace SQLInsight.Repository;
+
+/// A statement's use summed over a time window. is customer data (local UI only).
+public sealed record TopQuery
+{
+ public required string QueryKey { get; init; }
+ public string? DatabaseName { get; init; }
+ public string? Text { get; init; }
+ public long Executions { get; init; }
+ public long WorkerTimeUs { get; init; }
+ public long ElapsedTimeUs { get; init; }
+ public long LogicalReads { get; init; }
+ public long PhysicalReads { get; init; }
+ public long LogicalWrites { get; init; }
+}
+
+/// What a top-SQL list is sorted by.
+public enum TopQueryOrder
+{
+ Cpu,
+ Duration,
+ Reads,
+ Executions,
+}
+
+/// Wait time summed over a span of whole hours (hourly history).
+public sealed record HourlyWaitTotal(string WaitType, long WaitTimeMs, long WaitingTasks, int Hours);
+
+public sealed partial class InsightRepository
+{
+ /// At most this many statements are stored per collection: the heaviest by CPU.
+ public const int MaxQueriesPerSample = 100;
+
+ private static async Task SaveQueryStatsAsync(SqlConnection connection, int instanceId, QueryStatsDelta delta)
+ {
+ var queries = delta.Queries.Take(MaxQueriesPerSample).ToList();
+ if (queries.Count == 0)
+ {
+ return;
+ }
+
+ await connection.ExecuteAsync("""
+ INSERT insight.QueryStatSamples (InstanceId, SampledAt, IntervalSeconds, QueryKey, Executions, WorkerTimeUs, ElapsedTimeUs, LogicalReads, PhysicalReads, LogicalWrites)
+ SELECT @instanceId, @at, @interval, j.[Key], j.Executions, j.WorkerTimeUs, j.ElapsedTimeUs, j.LogicalReads, j.PhysicalReads, j.LogicalWrites
+ FROM OPENJSON(@json) WITH ([Key] varchar(200), Executions bigint, WorkerTimeUs bigint, ElapsedTimeUs bigint,
+ LogicalReads bigint, PhysicalReads bigint, LogicalWrites bigint) j
+ WHERE NOT EXISTS (SELECT 1 FROM insight.QueryStatSamples s WHERE s.InstanceId = @instanceId AND s.SampledAt = @at AND s.QueryKey = j.[Key]);
+
+ MERGE insight.QueryTexts AS t
+ USING (SELECT [Key], MAX(DatabaseName) AS DatabaseName, MAX([Text]) AS [Text]
+ FROM OPENJSON(@json) WITH ([Key] varchar(200), DatabaseName nvarchar(128), [Text] nvarchar(4000)) GROUP BY [Key]) AS s
+ ON t.InstanceId = @instanceId AND t.QueryKey = s.[Key]
+ WHEN MATCHED THEN UPDATE SET LastSeenAt = @at, QueryText = COALESCE(t.QueryText, s.[Text]), DatabaseName = COALESCE(t.DatabaseName, s.DatabaseName)
+ WHEN NOT MATCHED THEN INSERT (InstanceId, QueryKey, DatabaseName, QueryText, FirstSeenAt, LastSeenAt) VALUES (@instanceId, s.[Key], s.DatabaseName, s.[Text], @at, @at);
+ """, new
+ {
+ instanceId,
+ at = Utc(delta.To),
+ interval = (int)(delta.To - delta.From).TotalSeconds,
+ json = JsonSerializer.Serialize(queries, Json),
+ }).ConfigureAwait(false);
+ }
+
+ /// The statements that used the most in [from, to), with their text.
+ public async Task> GetTopQueriesAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, TopQueryOrder order = TopQueryOrder.Cpu, int top = 25, CancellationToken cancellationToken = default)
+ {
+ var orderBy = order switch
+ {
+ TopQueryOrder.Duration => "SUM(s.ElapsedTimeUs)",
+ TopQueryOrder.Reads => "SUM(s.LogicalReads)",
+ TopQueryOrder.Executions => "SUM(s.Executions)",
+ _ => "SUM(s.WorkerTimeUs)",
+ };
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync($"""
+ SELECT TOP (@top) s.QueryKey, MAX(t.DatabaseName) AS DatabaseName, MAX(t.QueryText) AS [Text],
+ SUM(s.Executions) AS Executions, SUM(s.WorkerTimeUs) AS WorkerTimeUs, SUM(s.ElapsedTimeUs) AS ElapsedTimeUs,
+ SUM(s.LogicalReads) AS LogicalReads, SUM(s.PhysicalReads) AS PhysicalReads, SUM(s.LogicalWrites) AS LogicalWrites
+ FROM insight.QueryStatSamples s
+ LEFT JOIN insight.QueryTexts t ON t.InstanceId = s.InstanceId AND t.QueryKey = s.QueryKey
+ WHERE s.InstanceId = @instanceId AND s.SampledAt > @from AND s.SampledAt <= @to
+ GROUP BY s.QueryKey
+ ORDER BY {orderBy} DESC;
+ """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime, top }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ /// Raw 5-minute samples of a metric in [from, to].
+ public async Task> GetMetricSamplesAsync(int instanceId, string metric, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync(
+ "SELECT SampledAt AS At, Value FROM insight.MetricSamples WHERE InstanceId = @instanceId AND Metric = @metric AND SampledAt >= @from AND SampledAt <= @to ORDER BY SampledAt;",
+ new { instanceId, metric, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ /// Hourly averages of a metric for the hours overlapping [from, to).
+ public async Task> GetMetricHourlyAsync(int instanceId, string metric, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync(
+ "SELECT [Hour] AS At, AvgValue AS Value FROM insight.MetricHourly WHERE InstanceId = @instanceId AND Metric = @metric AND [Hour] >= @from AND [Hour] < @to ORDER BY [Hour];",
+ new { instanceId, metric, from = FloorHour(from), to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ /// Waits from the 5-minute samples taken in (from, to].
+ public async Task> GetWaitsAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ SELECT WaitType, SUM(WaitingTasks) AS WaitingTasks, SUM(WaitTimeMs) AS WaitTimeMs, SUM(SignalWaitTimeMs) AS SignalWaitTimeMs
+ FROM insight.WaitSamples WHERE InstanceId = @instanceId AND SampledAt > @from AND SampledAt <= @to
+ GROUP BY WaitType ORDER BY SUM(WaitTimeMs) DESC;
+ """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ /// Waits from the hourly history for the hours overlapping [from, to), with how many hours had data.
+ public async Task> GetWaitsHourlyAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ SELECT w.WaitType, SUM(w.WaitTimeMs) AS WaitTimeMs, SUM(w.WaitingTasks) AS WaitingTasks,
+ (SELECT COUNT(DISTINCT h.[Hour]) FROM insight.WaitHourly h WHERE h.InstanceId = @instanceId AND h.[Hour] >= @from AND h.[Hour] < @to) AS Hours
+ FROM insight.WaitHourly w WHERE w.InstanceId = @instanceId AND w.[Hour] >= @from AND w.[Hour] < @to
+ GROUP BY w.WaitType;
+ """, new { instanceId, from = FloorHour(from), to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ public async Task> GetFileLatencyAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync("""
+ 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 AND SampledAt <= @to
+ GROUP BY DatabaseName, FileId
+ ORDER BY SUM(ReadStallMs) + SUM(WriteStallMs) DESC;
+ """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.ToList();
+ }
+
+ public async Task> GetBlockingAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, 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 AND SampledAt <= @to ORDER BY SampledAt;
+ """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.Select(r => new StoredBlocking(r.SampledAt, r.HeadSessionId, r.BlockedCount, r.MaxWaitMs,
+ JsonSerializer.Deserialize>(r.SessionsJson, Json) ?? [])).ToList();
+ }
+
+ public async Task> GetDeadlocksAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, 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 AND OccurredAt <= @to ORDER BY OccurredAt;
+ """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.Select(r => new StoredDeadlock(r.DeadlockId, r.OccurredAt, JsonSerializer.Deserialize>(r.ProcessesJson, Json) ?? [], r.GraphXml)).ToList();
+ }
+
+ /// Findings that were active at some point in [from, to] (first seen before the end, not resolved before the start).
+ public async Task> GetFindingsActiveDuringAsync(int instanceId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync($"""
+ SELECT TOP (200) {FindingColumns}
+ FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
+ WHERE f.InstanceId = @instanceId AND f.FirstSeenAt <= @to AND (f.ResolvedAt IS NULL OR f.ResolvedAt >= @from)
+ ORDER BY f.Severity DESC, f.FirstSeenAt;
+ """, new { instanceId, from = from.UtcDateTime, to = to.UtcDateTime }).ConfigureAwait(false);
+ return rows.Select(Map).ToList();
+ }
+
+ private static DateTime FloorHour(DateTimeOffset value)
+ {
+ var utc = value.UtcDateTime;
+ return new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);
+ }
+}
diff --git a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs
index 6a38ad1..7f409ad 100644
--- a/src/SQLInsight.Repository/InsightRepository.Maintenance.cs
+++ b/src/SQLInsight.Repository/InsightRepository.Maintenance.cs
@@ -63,6 +63,8 @@ public sealed partial class InsightRepository
await DeleteInBatchesAsync(connection, "insight.MetricSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.WaitSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.FileIoSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
+ await DeleteInBatchesAsync(connection, "insight.QueryStatSamples", "SampledAt < @cutoff", raw).ConfigureAwait(false);
+ await DeleteInBatchesAsync(connection, "insight.QueryTexts", "LastSeenAt < @cutoff", raw).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.MetricHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.WaitHourly", "[Hour] < @cutoff", hourly).ConfigureAwait(false);
await DeleteInBatchesAsync(connection, "insight.BlockingEvents", "SampledAt < @cutoff", events).ConfigureAwait(false);
diff --git a/src/SQLInsight.Repository/InsightRepository.cs b/src/SQLInsight.Repository/InsightRepository.cs
index 82689d0..90a546c 100644
--- a/src/SQLInsight.Repository/InsightRepository.cs
+++ b/src/SQLInsight.Repository/InsightRepository.cs
@@ -198,6 +198,12 @@ public sealed partial class InsightRepository(string connectionString)
case DeadlockSnapshot deadlocks:
await SaveDeadlocksAsync(connection, instanceId, deadlocks).ConfigureAwait(false);
return;
+ case CumulativeSample { Delta: { } queries }:
+ await SaveQueryStatsAsync(connection, instanceId, queries).ConfigureAwait(false);
+ return;
+ case CumulativeSample:
+ // Never as a JSON snapshot: it holds statement texts.
+ return;
default:
await connection.ExecuteAsync("""
MERGE insight.Snapshots AS t
diff --git a/src/SQLInsight.Repository/Migrations/0006_query_stats.sql b/src/SQLInsight.Repository/Migrations/0006_query_stats.sql
new file mode 100644
index 0000000..11921f1
--- /dev/null
+++ b/src/SQLInsight.Repository/Migrations/0006_query_stats.sql
@@ -0,0 +1,28 @@
+/* Top SQL: what the heaviest statements used per collection (deltas of sys.dm_exec_query_stats), for the time window
+ analysis. Kept as long as the other raw samples (Retention:RawDays). */
+CREATE TABLE insight.QueryStatSamples
+(
+ InstanceId int NOT NULL,
+ SampledAt datetime2(0) NOT NULL,
+ IntervalSeconds int NOT NULL,
+ QueryKey varchar(200) NOT NULL,
+ Executions bigint NOT NULL,
+ WorkerTimeUs bigint NOT NULL,
+ ElapsedTimeUs bigint NOT NULL,
+ LogicalReads bigint NOT NULL,
+ PhysicalReads bigint NOT NULL,
+ LogicalWrites bigint NOT NULL,
+ CONSTRAINT PK_QueryStatSamples PRIMARY KEY (InstanceId, SampledAt, QueryKey)
+);
+
+/* The statement text once per statement. Customer data: shown in the local web UI only, never sent to the hub. */
+CREATE TABLE insight.QueryTexts
+(
+ InstanceId int NOT NULL,
+ QueryKey varchar(200) NOT NULL,
+ DatabaseName nvarchar(128) NULL,
+ QueryText nvarchar(4000) NULL,
+ FirstSeenAt datetime2(0) NOT NULL,
+ LastSeenAt datetime2(0) NOT NULL,
+ CONSTRAINT PK_QueryTexts PRIMARY KEY (InstanceId, QueryKey)
+);
diff --git a/tests/SQLInsight.Core.Tests/QueryStatsDeltaTests.cs b/tests/SQLInsight.Core.Tests/QueryStatsDeltaTests.cs
new file mode 100644
index 0000000..5ad397e
--- /dev/null
+++ b/tests/SQLInsight.Core.Tests/QueryStatsDeltaTests.cs
@@ -0,0 +1,134 @@
+using SQLInsight.Core.Data;
+
+namespace SQLInsight.Core.Tests;
+
+public class QueryStatsDeltaTests
+{
+ private static readonly DateTime Start = new(2026, 9, 20, 0, 0, 0);
+ private static readonly DateTimeOffset T0 = new(2026, 9, 20, 3, 0, 0, TimeSpan.Zero);
+
+ private static QueryStat Stat(string key, long executions, long cpu, DateTime created, DateTime lastRun, long reads = 0) => new()
+ {
+ Key = key,
+ Text = "SELECT " + key,
+ Executions = executions,
+ WorkerTimeUs = cpu,
+ ElapsedTimeUs = cpu * 2,
+ LogicalReads = reads,
+ PhysicalReads = 0,
+ LogicalWrites = 0,
+ CreatedAt = created,
+ LastExecutionAt = lastRun,
+ };
+
+ private static QueryStatsSnapshot Snapshot(DateTimeOffset at, DateTime serverTime, params QueryStat[] queries) => new()
+ {
+ CapturedAt = at,
+ ServerTime = serverTime,
+ SqlServerStartTime = Start,
+ Queries = queries,
+ };
+
+ [Fact]
+ public void Statement_seen_before_with_the_same_plan_gets_the_difference()
+ {
+ var created = new DateTime(2026, 9, 20, 1, 0, 0);
+ var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xA", 100, 5_000, created, created.AddHours(1), reads: 1_000));
+ var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0), Stat("0xA", 130, 8_000, created, new DateTime(2026, 9, 20, 3, 4, 0), reads: 1_600));
+
+ var delta = QueryStatsDelta.Compute(before, now)!;
+
+ var q = Assert.Single(delta.Queries);
+ Assert.Equal(30, q.Executions);
+ Assert.Equal(3_000, q.WorkerTimeUs);
+ Assert.Equal(6_000, q.ElapsedTimeUs);
+ Assert.Equal(600, q.LogicalReads);
+ Assert.Equal("SELECT 0xA", q.Text);
+ }
+
+ [Fact]
+ public void Plan_compiled_after_the_previous_collection_counts_in_full()
+ {
+ var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0));
+ var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
+ Stat("0xB", 4, 900, new DateTime(2026, 9, 20, 3, 2, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
+
+ var q = Assert.Single(QueryStatsDelta.Compute(before, now)!.Queries);
+
+ Assert.Equal(4, q.Executions);
+ Assert.Equal(900, q.WorkerTimeUs);
+ }
+
+ [Fact]
+ public void Old_statement_seen_for_the_first_time_has_no_known_starting_point()
+ {
+ var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0));
+ var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
+ Stat("0xC", 1_000_000, 9_000_000, new DateTime(2026, 9, 19, 8, 0, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
+
+ Assert.Empty(QueryStatsDelta.Compute(before, now)!.Queries);
+ }
+
+ [Fact]
+ public void Recompiled_plan_starts_over_without_negative_numbers()
+ {
+ var oldPlan = new DateTime(2026, 9, 20, 1, 0, 0);
+ var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xD", 500, 50_000, oldPlan, oldPlan.AddHours(1)));
+
+ // Evicted and compiled again at 03:03: smaller totals, newer creation time. Everything since 03:03 is new.
+ var recompiled = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
+ Stat("0xD", 7, 700, new DateTime(2026, 9, 20, 3, 3, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
+ var q = Assert.Single(QueryStatsDelta.Compute(before, recompiled)!.Queries);
+ Assert.Equal(7, q.Executions);
+ Assert.Equal(700, q.WorkerTimeUs);
+
+ // Counters went backwards but the oldest plan is from before: one of several plans was evicted. Skip, never negative.
+ var partlyEvicted = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
+ Stat("0xD", 300, 30_000, oldPlan, new DateTime(2026, 9, 20, 3, 4, 0)));
+ Assert.Empty(QueryStatsDelta.Compute(before, partlyEvicted)!.Queries);
+ }
+
+ [Fact]
+ public void Restart_gives_no_delta_and_forgets_remembered_statements()
+ {
+ var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xE", 1, 1, Start, Start));
+ var after = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0)) with { SqlServerStartTime = Start.AddHours(3) };
+
+ Assert.Null(QueryStatsDelta.Compute(before, after));
+ Assert.Empty(QueryStatsDelta.Remember(before, [], after.SqlServerStartTime));
+ }
+
+ [Fact]
+ public void Statements_not_run_now_are_remembered_for_later_up_to_a_limit()
+ {
+ var created = new DateTime(2026, 9, 20, 1, 0, 0);
+ var previous = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0),
+ [.. Enumerable.Range(0, QueryStatsSnapshot.MaxRemembered + 10).Select(i => Stat("0x" + i, 1, 1, created, created.AddSeconds(i)))]);
+ var current = new[] { Stat("0xNEW", 1, 1, created, created.AddHours(2)) };
+
+ var kept = QueryStatsDelta.Remember(previous, current, Start);
+
+ Assert.Equal(QueryStatsSnapshot.MaxRemembered, kept.Count);
+ Assert.Equal("0xNEW", kept[0].Key);
+ Assert.DoesNotContain(kept, q => q.Key == "0x0"); // least recently run is forgotten first
+ }
+
+ [Fact]
+ public void Sql_server_2005_key_is_handle_and_offsets()
+ {
+ // The key is whatever the collector builds; on 2005 sql_handle:start:end, so the same batch's statements stay apart.
+ var created = new DateTime(2026, 9, 20, 1, 0, 0);
+ var first = "0x0200000012ab:0:120";
+ var second = "0x0200000012ab:122:-1";
+ var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat(first, 10, 100, created, created), Stat(second, 10, 100, created, created));
+ var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
+ Stat(first, 12, 160, created, new DateTime(2026, 9, 20, 3, 1, 0)), Stat(second, 15, 400, created, new DateTime(2026, 9, 20, 3, 1, 0)));
+
+ var delta = QueryStatsDelta.Compute(before, now)!;
+
+ Assert.Equal(2, delta.Queries.Count);
+ Assert.Equal(second, delta.Queries[0].Key); // most CPU first
+ Assert.Equal(300, delta.Queries[0].WorkerTimeUs);
+ Assert.Equal(60, delta.Queries[1].WorkerTimeUs);
+ }
+}