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); + } +}