SQLInsight Web: /instanser/{navn}/analyse?fra=&til= (datetime-local in the
display time zone; presets last hour, tonight 00-06, yesterday; at most 31
days). "Analysér et tidsrum" on the instance page, and blocking/deadlock
times there open the window half an hour around the event.
For the window: load (CPU, batch requests, compilations, PLE, connections,
memory grants pending, blocked processes) from 5-minute samples within
Retention:RawDays, otherwise hourly history (shown on the page); waits per
minute against the same hours and weekday of the previous 4 weeks, with
clear deviations marked; top SQL sortable by CPU, duration, reads and
executions; file I/O latency; blocking chains; deadlocks; findings active
in the window. An empty window explains the retention. "Hent som PDF"
renders the same via MigraDoc (/analyse.pdf).
Docs: docs/analyse.md (also on the community site); databehandling.md: the
statement text stays in the customer's repository and is never sent to
the hub.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
218 lines
12 KiB
C#
218 lines
12 KiB
C#
using Dapper;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.Data.SqlClient;
|
|
using SQLInsight.Collectors.Performance;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.Instances;
|
|
using SQLInsight.Reports;
|
|
using SQLInsight.Repository;
|
|
|
|
namespace SQLInsight.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Top SQL against real SQL Server (as the least-privilege agent), and the time window analysis on stored data:
|
|
/// repository queries, the web page and the PDF.
|
|
/// </summary>
|
|
[Collection(SqlServerTestGroup.Name)]
|
|
[Trait("Category", "Integration")]
|
|
public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime
|
|
{
|
|
private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
|
|
|
|
private readonly string _database = "SQLInsightWindow_" + Guid.NewGuid().ToString("N")[..8];
|
|
private InsightRepository _repository = null!;
|
|
private int _instanceId;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
|
|
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
|
|
await _repository.MigrateAsync();
|
|
var info = new InstanceInfo
|
|
{
|
|
ServerName = "AX01",
|
|
Version = SqlServerVersion.Parse("16.0.4135.4"),
|
|
ProductLevel = "RTM",
|
|
Edition = "Standard Edition",
|
|
EngineEdition = EngineEdition.Standard,
|
|
};
|
|
_instanceId = await _repository.UpsertInstanceAsync(@"AX01\AX30", info, "SqlClient", false, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
public async Task DisposeAsync() =>
|
|
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
|
|
|
|
[Fact]
|
|
public async Task Collector_turns_query_stats_into_deltas_with_text()
|
|
{
|
|
var table = "QsProbe_" + Guid.NewGuid().ToString("N")[..8];
|
|
await sql.ExecuteAsSaAsync($"USE [{SqlServerFixture.TestDatabase}]; CREATE TABLE dbo.{table} (Id int NOT NULL PRIMARY KEY, Amount money NOT NULL); INSERT dbo.{table} SELECT TOP (2000) ROW_NUMBER() OVER (ORDER BY (SELECT 1)), 1 FROM sys.all_columns a CROSS JOIN sys.all_columns b;");
|
|
var probe = $"SELECT SUM(p.Amount) FROM dbo.{table} p JOIN dbo.{table} q ON q.Id = p.Id WHERE p.Id > 0";
|
|
|
|
await using var session = await AgentSession.OpenAsync(sql);
|
|
var collector = new QueryStatsCollector();
|
|
var first = await session.CollectAsync<CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>>(collector);
|
|
await Task.Delay(1100);
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
await RunInTestDatabaseAsync(probe);
|
|
}
|
|
|
|
var second = await session.CollectAsync(collector, first);
|
|
await Task.Delay(1100);
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
await RunInTestDatabaseAsync(probe);
|
|
}
|
|
|
|
var third = await session.CollectAsync(collector, second);
|
|
|
|
Assert.Null(first.Delta);
|
|
// Compiled after the first collection: all five executions are new.
|
|
var compiled = Assert.Single(second.Delta!.Queries, q => q.Text?.Contains(table, StringComparison.Ordinal) == true);
|
|
Assert.Equal(5, compiled.Executions);
|
|
Assert.True(compiled.WorkerTimeUs > 0);
|
|
Assert.True(compiled.LogicalReads > 0);
|
|
Assert.Equal(SqlServerFixture.TestDatabase, compiled.DatabaseName);
|
|
Assert.StartsWith("0x", compiled.Key, StringComparison.Ordinal);
|
|
// Same plan: only the difference.
|
|
Assert.Equal(3, Assert.Single(third.Delta!.Queries, q => q.Key == compiled.Key).Executions);
|
|
Assert.Contains(third.Snapshot.Queries, q => q.Key == compiled.Key);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Repository_sums_top_sql_per_window_and_never_keeps_it_as_a_snapshot()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
await SaveQueryDeltaAsync(now.AddMinutes(-20), ("0xAAA", "SELECT * FROM CUSTTABLE", 10, 4_000_000), ("0xBBB", "SELECT * FROM INVENTTRANS", 1, 9_000_000));
|
|
await SaveQueryDeltaAsync(now.AddMinutes(-10), ("0xAAA", "SELECT * FROM CUSTTABLE", 5, 2_000_000));
|
|
await SaveQueryDeltaAsync(now.AddHours(-5), ("0xAAA", "SELECT * FROM CUSTTABLE", 100, 100_000_000)); // outside the window
|
|
|
|
var byCpu = await _repository.GetTopQueriesAsync(_instanceId, now.AddHours(-1), now);
|
|
var byExecutions = await _repository.GetTopQueriesAsync(_instanceId, now.AddHours(-1), now, TopQueryOrder.Executions);
|
|
|
|
Assert.Equal(["0xBBB", "0xAAA"], byCpu.Select(q => q.QueryKey));
|
|
var custtable = byCpu.Single(q => q.QueryKey == "0xAAA");
|
|
Assert.Equal(15, custtable.Executions);
|
|
Assert.Equal(6_000_000, custtable.WorkerTimeUs);
|
|
Assert.Equal("SELECT * FROM CUSTTABLE", custtable.Text);
|
|
Assert.Equal("0xAAA", byExecutions[0].QueryKey);
|
|
|
|
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
|
|
Assert.Equal(0, await connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM insight.Snapshots WHERE Kind = 'query-stats' OR Json LIKE '%CUSTTABLE%'"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Analysis_compares_the_window_with_the_same_hours_in_earlier_weeks()
|
|
{
|
|
var to = FloorHour(DateTimeOffset.UtcNow).AddHours(-1);
|
|
var from = to.AddHours(-1);
|
|
await SeedWindowAsync(from, to);
|
|
|
|
var analysis = await TimeWindowAnalysis.LoadAsync(_repository, (await _repository.GetInstanceAsync(@"AX01\AX30"))!, from, to, DateTimeOffset.UtcNow);
|
|
|
|
Assert.Equal(WindowResolution.Raw, analysis.Resolution);
|
|
Assert.True(analysis.HasData);
|
|
var locks = analysis.Waits.Single(w => w.WaitType == "LCK_M_X");
|
|
Assert.Equal(1_000, locks.NormalPerMinute); // 60 s per hour in each earlier week
|
|
Assert.True(locks.IsUnusual);
|
|
var cpu = analysis.Metrics.Single(m => m.Metric == Metrics.CpuSqlServer);
|
|
Assert.Equal(90, cpu.Average);
|
|
Assert.Equal(30, cpu.NormalAverage);
|
|
Assert.True(cpu.IsUnusual);
|
|
Assert.Equal("SELECT * FROM SALESLINE", Assert.Single(analysis.TopQueries).Text);
|
|
Assert.Single(analysis.Blocking);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Page_and_pdf_show_the_window_and_explain_an_empty_one()
|
|
{
|
|
var to = FloorHour(DateTimeOffset.UtcNow).AddHours(-1);
|
|
var from = to.AddHours(-1);
|
|
await SeedWindowAsync(from, to);
|
|
await using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
|
|
{
|
|
b.UseEnvironment("Development");
|
|
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
|
|
b.UseSetting("SQLInsight:Web:Authentication", "None");
|
|
b.UseSetting("SQLInsight:Web:TimeZone", "Europe/Copenhagen");
|
|
});
|
|
var client = factory.CreateClient();
|
|
var name = Uri.EscapeDataString(@"AX01\AX30");
|
|
static string Window(DateTimeOffset f, DateTimeOffset t) =>
|
|
$"fra={Uri.EscapeDataString(TimeWindow.ToInput(f, Copenhagen))}&til={Uri.EscapeDataString(TimeWindow.ToInput(t, Copenhagen))}";
|
|
|
|
var html = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(from, to)}");
|
|
Assert.Contains("SELECT * FROM SALESLINE", html, StringComparison.Ordinal);
|
|
Assert.Contains("LCK_M_X", html, StringComparison.Ordinal);
|
|
Assert.Contains("class=\"unusual\"", html, StringComparison.Ordinal);
|
|
Assert.Contains("value=\"" + TimeWindow.ToInput(from, Copenhagen) + "\"", html, StringComparison.Ordinal);
|
|
|
|
var empty = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(from.AddDays(-200), to.AddDays(-200))}");
|
|
Assert.Contains("Der er ingen målinger i dette tidsrum", empty, StringComparison.Ordinal);
|
|
|
|
var invalid = await client.GetStringAsync($"/instanser/{name}/analyse?{Window(to, from)}");
|
|
Assert.Contains("Til skal ligge efter fra.", invalid, StringComparison.Ordinal);
|
|
|
|
var instancePage = await client.GetStringAsync($"/instanser/{name}");
|
|
Assert.Contains($"/instanser/{name}/analyse", instancePage, StringComparison.Ordinal);
|
|
|
|
using var pdf = await client.GetAsync($"/analyse.pdf?instans={name}&{Window(from, to)}");
|
|
Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
|
|
Assert.StartsWith("%PDF", System.Text.Encoding.ASCII.GetString(await pdf.Content.ReadAsByteArrayAsync())[..4], StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>A busy hour: high CPU, lock waits and one heavy statement, against quiet same hours four weeks back.</summary>
|
|
private async Task SeedWindowAsync(DateTimeOffset from, DateTimeOffset to)
|
|
{
|
|
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
|
|
for (var at = from.AddMinutes(5); at <= to; at = at.AddMinutes(5))
|
|
{
|
|
await connection.ExecuteAsync("""
|
|
INSERT insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES (@id, @cpu, @at, 90), (@id, @batches, @at, 450);
|
|
INSERT insight.WaitSamples (InstanceId, SampledAt, WaitType, IntervalSeconds, WaitingTasks, WaitTimeMs, SignalWaitTimeMs)
|
|
VALUES (@id, @at, 'LCK_M_X', 300, 40, 600000, 0), (@id, @at, 'PAGEIOLATCH_SH', 300, 900, 30000, 100);
|
|
""", new { id = _instanceId, cpu = Metrics.CpuSqlServer, batches = Metrics.BatchRequestsPerSecond, at = at.UtcDateTime });
|
|
}
|
|
|
|
for (var week = 1; week <= WindowBaseline.Weeks; week++)
|
|
{
|
|
var hour = from.AddDays(-7 * week).UtcDateTime;
|
|
await connection.ExecuteAsync("""
|
|
INSERT insight.MetricHourly (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples) VALUES (@id, @cpu, @hour, 30, 40, 12);
|
|
INSERT insight.WaitHourly (InstanceId, [Hour], WaitType, WaitingTasks, WaitTimeMs, SignalWaitTimeMs)
|
|
VALUES (@id, @hour, 'LCK_M_X', 10, 60000, 0), (@id, @hour, 'PAGEIOLATCH_SH', 10000, 360000, 1000);
|
|
""", new { id = _instanceId, cpu = Metrics.CpuSqlServer, hour });
|
|
}
|
|
|
|
await connection.ExecuteAsync("""
|
|
INSERT insight.BlockingEvents (InstanceId, SampledAt, HeadSessionId, BlockedCount, MaxWaitMs, SessionsJson)
|
|
VALUES (@id, @at, 55, 7, 42000, '[{"SessionId":55,"Status":"sleeping","ProgramName":"Microsoft Dynamics AX","LoginName":"AXSERVICE"}]');
|
|
""", new { id = _instanceId, at = from.AddMinutes(20).UtcDateTime });
|
|
await SaveQueryDeltaAsync(from.AddMinutes(30), ("0xCCC", "SELECT * FROM SALESLINE", 4000, 120_000_000));
|
|
}
|
|
|
|
private Task SaveQueryDeltaAsync(DateTimeOffset at, params (string Key, string Text, long Executions, long Cpu)[] queries) =>
|
|
_repository.SaveCollectedAsync(_instanceId, "query-stats", new CumulativeSample<QueryStatsSnapshot, QueryStatsDelta>(
|
|
new QueryStatsSnapshot { CapturedAt = at, ServerTime = at.UtcDateTime, SqlServerStartTime = DateTime.UtcNow.AddDays(-30), Queries = [] },
|
|
new QueryStatsDelta
|
|
{
|
|
From = at.AddMinutes(-5),
|
|
To = at,
|
|
Queries = [.. queries.Select(q => new QueryStatDelta
|
|
{
|
|
Key = q.Key, Text = q.Text, DatabaseName = "AX30", Executions = q.Executions, WorkerTimeUs = q.Cpu, ElapsedTimeUs = q.Cpu * 2,
|
|
LogicalReads = q.Executions * 100, PhysicalReads = 0, LogicalWrites = 0,
|
|
})],
|
|
}));
|
|
|
|
/// <summary>Like an application: connected to its own database, so the plan belongs to it.</summary>
|
|
private async Task RunInTestDatabaseAsync(string statement)
|
|
{
|
|
await using var connection = new SqlConnection(sql.DatabaseConnectionString(SqlServerFixture.TestDatabase));
|
|
await connection.ExecuteScalarAsync(statement);
|
|
}
|
|
|
|
private static DateTimeOffset FloorHour(DateTimeOffset value) => new(value.Year, value.Month, value.Day, value.Hour, 0, 0, value.Offset);
|
|
}
|