From 7a908cc28bf2ba04969c1f589b80803017c643dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Eggert=20Lundsteen=20Olsen?= Date: Fri, 2 Oct 2026 08:57:10 +0200 Subject: [PATCH] Explain and assess measurements for the customer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MetricAssessment rates CPU (SQL/other), batch requests, compilations (against batches), page life expectancy (300 s per 4 GB buffer pool, average and dips over 50 %, max server memory unset), user connections, memory grants pending, processes blocked and disk latency as OK, Bemærk or Reagér with a concrete next step and link, plus a plain Danish "Hvad betyder det?" for each. Shown under every graph on the instance page (now all eight metrics) and in the analysis, per drive in the disk table, and in the analysis PDF. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/analyse.md | 19 + src/SQLInsight.Reports/MetricAssessment.cs | 330 ++++++++++++++++++ src/SQLInsight.Reports/TimeWindowAnalysis.cs | 17 + src/SQLInsight.Reports/TimeWindowReport.cs | 17 + .../Components/Pages/Analysis.razor | 3 +- .../Components/Pages/Instance.razor | 32 +- .../Components/Shared/AssessmentNote.razor | 27 ++ .../Components/Shared/DiskTable.razor | 5 +- src/SQLInsight.Web/wwwroot/app.css | 8 + .../MetricAssessmentTests.cs | 107 ++++++ .../TimeWindowAnalysisTests.cs | 5 + 11 files changed, 558 insertions(+), 12 deletions(-) create mode 100644 src/SQLInsight.Reports/MetricAssessment.cs create mode 100644 src/SQLInsight.Web/Components/Shared/AssessmentNote.razor create mode 100644 tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs diff --git a/docs/analyse.md b/docs/analyse.md index d4aa5c3..ad19956 100644 --- a/docs/analyse.md +++ b/docs/analyse.md @@ -74,3 +74,22 @@ Ventetidsstatistikken (`sys.dm_os_wait_stats`) siger hvor meget der ventes, men - Ventetider, der varer under 15 sekunder i alt, kan falde mellem to stikprøver. Tallene er et billede af, hvor ventetiden ligger, ikke en præcis måling. - Virker fra SQL Server 2005 (her er nøglen batch og position, fordi `query_hash` først kom i 2008). Intervallet kan ændres med `SQLInsight:CollectorIntervals:wait-sampling`. - Sætningernes tekst er kundens data og sendes aldrig til SQLInsight Hub. + +## Hvad betyder målingerne? + +Under hver graf på instanssiden og i analysen står en vurdering i farve – **OK** (grøn), **Bemærk** (gul) eller **Reagér** (rød) – med et konkret næste skridt og et link, og en fold-ud "Hvad betyder det?" med en forklaring i almindeligt sprog. Samme vurdering står i PDF'en. Reglerne: + +| Måling | Bemærk | Reagér | +|---|---|---| +| CPU (SQL Server + andre) | Gns. over 60 % | Over 80 % i mindst 3 målinger i træk; peger på andre processer, hvis de bruger over 20 % eller mere end SQL Server | +| CPU brugt af andre processer | Gns. 10 % eller mere | Over 30 % vedvarende, eller gns. 20 % og mere end SQL Server | +| Page life expectancy | Laveste værdi under grænsen, et fald på over 50 % undervejs, eller max server memory ikke sat | Gennemsnit under grænsen: 300 s pr. 4 GB buffer pool (max server memory, ellers serverens hukommelse) | +| SQL compilations | Over 10 % af batch requests | Over 20 % af batch requests (kun ved mindst 5 batches/s) | +| Memory grants pending, blokerede processer | Over 0 kortvarigt | Over 0 i mindst 3 målinger i træk | +| Brugerforbindelser | Højeste over dobbelt gennemsnit | – | +| Batch requests | Altid OK: et mål for travlhed | – | +| Disk, læsning fra datafiler | Over 10 ms | Over 20 ms | +| Disk, skrivning til datafiler | Over 10 ms | Over 20 ms | +| Disk, skrivning til log (drev kun med logfiler) | Over 5 ms | Over 15 ms | + +Diske vurderes kun med mindst 100 læsninger/skrivninger i perioden. Logikken ligger ét sted (`MetricAssessment`), så siden og PDF'en siger det samme. diff --git a/src/SQLInsight.Reports/MetricAssessment.cs b/src/SQLInsight.Reports/MetricAssessment.cs new file mode 100644 index 0000000..521b748 --- /dev/null +++ b/src/SQLInsight.Reports/MetricAssessment.cs @@ -0,0 +1,330 @@ +using System.Globalization; +using SQLInsight.Core.Data; +using SQLInsight.Repository; + +namespace SQLInsight.Reports; + +public enum AssessmentLevel +{ + /// Not enough data to say. + Unknown, + Ok, + + /// "Bemærk": worth knowing, not urgent. + Note, + + /// "Reagér": act on it. + Act, +} + +/// What the numbers say, in one or two sentences. +/// The concrete next action; null when there is nothing to do. +/// A page in SQLInsight Pro (relative to the instance page) or documentation for the next step. +public sealed record Assessment(AssessmentLevel Level, string Summary, string? NextStep = null, string? Link = null) +{ + public string LevelText => MetricAssessment.LevelText(Level); +} + +/// What is known about the instance besides the measurement itself. +/// The memory SQL Server may use for data: max server memory, or else the server's memory. +/// False when max server memory is SQL Server's default (unlimited). +public sealed record MetricContext( + double? BufferPoolGb = null, + bool? MaxServerMemorySet = null, + IReadOnlyList? BatchRequests = null, + IReadOnlyList? CpuSql = null, + IReadOnlyList? CpuOther = null) +{ + public static MetricContext From(ConfigurationSnapshot? configuration, ServerSnapshot? server, + IReadOnlyList? batchRequests = null, IReadOnlyList? cpuSql = null, IReadOnlyList? cpuOther = null) + { + var maxMb = configuration?.Find("max server memory (MB)")?.ValueInUse; + bool? set = maxMb is null ? null : maxMb < 2147483647; + double? poolMb = set == true ? maxMb : server?.PhysicalMemoryMb is > 0 and var physical ? physical : null; + if (set == true && server?.PhysicalMemoryMb is > 0 and var total) + { + poolMb = Math.Min(poolMb!.Value, total); + } + + return new MetricContext(poolMb / 1024.0, set, batchRequests, cpuSql, cpuOther); + } +} + +/// +/// What a measurement means for the customer and how it looks: OK, "Bemærk" or "Reagér", with a concrete next step. +/// Shared by the instance page, the time window analysis and the PDFs, so they all say the same. +/// +public static class MetricAssessment +{ + /// The classic rule of thumb, scaled: 300 seconds per 4 GB of buffer pool. + public const double PleSecondsPer4Gb = 300; + + /// A count above 0 in this many consecutive samples is sustained, not a blip. + public const int Sustained = 3; + + private static readonly CultureInfo Danish = CultureInfo.GetCultureInfo("da-DK"); + + public static string LevelText(AssessmentLevel level) => level switch + { + AssessmentLevel.Ok => "OK", + AssessmentLevel.Note => "Bemærk", + AssessmentLevel.Act => "Reagér", + _ => "Ukendt", + }; + + /// "Hvad betyder det?" for a metric, in plain Danish. + public static string Explanation(string metric) => metric switch + { + Metrics.CpuSqlServer => + "Hvor meget af serverens samlede CPU SQL Server bruger. Høj CPU er ikke i sig selv et problem, men vedvarende over 80 % " + + "betyder, at forespørgsler må vente på at komme til, og at der ikke er luft til spidsbelastninger.", + Metrics.CpuOther => + "CPU brugt af andre programmer på samme server (antivirus, backup-agenter, applikationer, AOS). Den CPU mangler SQL Server. " + + "På en dedikeret databaseserver bør den være lav.", + Metrics.BatchRequestsPerSecond => + "Hvor mange forespørgsler (batches) applikationerne sender pr. sekund. Det er et mål for, hvor travlt serveren har det, " + + "ikke for et problem i sig selv. Brug det til at se, om en langsom periode skyldes mere arbejde end normalt.", + Metrics.CompilationsPerSecond => + "Hvor mange gange pr. sekund SQL Server skal lave en ny forespørgselsplan. Planer genbruges normalt; kompilering koster CPU. " + + "Sammenhold med batch requests: over ca. 10–20 % af batches tyder på forespørgsler, der ikke genbruger planer (literaler i stedet for parametre).", + Metrics.PageLifeExpectancy => + "Hvor længe (sekunder) en dataside i gennemsnit bliver i hukommelsen, før den skal læses fra disk igen. Højt er godt. " + + "Tommelfingerreglen er mindst 300 sekunder pr. 4 GB buffer pool. Pludselige fald betyder, at en forespørgsel har læst store mængder data og skubbet andet ud.", + Metrics.UserConnections => + "Antal forbindelser fra brugere og applikationer. Et mål for brugen; et pludseligt hop kan betyde, at forbindelser ikke lukkes (connection leak), eller at sessioner hænger og venter.", + Metrics.MemoryGrantsPending => + "Forespørgsler, der venter på at få hukommelse til sortering og hash, før de overhovedet kan starte. Bør altid være 0; " + + "vedvarende over 0 betyder, at hukommelsen er en flaskehals lige nu (ventetypen RESOURCE_SEMAPHORE).", + Metrics.ProcessesBlocked => + "Sessioner, der lige nu venter på en lås, som en anden session holder. Kortvarig blokering er normal; vedvarende blokering betyder, at brugere venter.", + "disk.read" => + "Gennemsnitlig tid pr. læsning fra datafilerne på drevet. Under 10 ms er godt for de fleste systemer, over 20 ms mærkes af brugerne.", + "disk.write.log" => + "Gennemsnitlig tid pr. skrivning til transaktionsloggen. Hver commit venter på den, så den skal være lav: under 5 ms, helst 1–2 ms.", + "disk.write" => + "Gennemsnitlig tid pr. skrivning til datafilerne. Skrivninger til datafiler sker mest i baggrunden (checkpoint), så de tåler mere end loggen; over 20 ms er højt.", + _ => "Ingen forklaring.", + }; + + /// The assessment of a metric's samples in a period. + public static Assessment Assess(string metric, IReadOnlyList points, MetricContext? context = null) + { + ArgumentNullException.ThrowIfNull(points); + context ??= new MetricContext(); + if (points.Count == 0) + { + return new(AssessmentLevel.Unknown, "Ingen målinger i perioden."); + } + + return metric switch + { + Metrics.CpuSqlServer => Cpu(points, context.CpuOther), + Metrics.CpuOther => CpuOther(points, context.CpuSql), + Metrics.PageLifeExpectancy => Ple(points, context), + Metrics.CompilationsPerSecond => Compilations(points, context.BatchRequests), + Metrics.MemoryGrantsPending => SustainedCount(points, + "Ingen forespørgsler har ventet på hukommelse.", + "forespørgsler ventede på hukommelse", + "Find forespørgslerne med store memory grants (ventetypen RESOURCE_SEMAPHORE), opdatér statistikker og tilføj indexes, så de sorterer mindre; tjek max server memory.", + "ventetyper/RESOURCE_SEMAPHORE"), + Metrics.ProcessesBlocked => SustainedCount(points, + "Ingen blokerede processer.", + "sessioner var blokeret", + "Se blocking-kæderne for, hvem der blokerer (head blocker), og hvilken sætning den kører; hold transaktioner korte.", + "analyse#blocking"), + Metrics.BatchRequestsPerSecond => new(AssessmentLevel.Ok, + $"Gennemsnitligt {N(Average(points), 1)} pr. sekund, højst {N(Peak(points), 1)}. Et mål for travlhed; sammenlign med normalen i analysen."), + Metrics.UserConnections => Connections(points), + _ => new(AssessmentLevel.Unknown, "Ingen vurdering."), + }; + } + + /// Disk latency on a drive: data reads, data writes, log writes. + public static Assessment AssessDisk(DiskActivity disk) + { + ArgumentNullException.ThrowIfNull(disk); + var worst = AssessmentLevel.Unknown; + var parts = new List(); + if (disk.AverageReadMs is { } read && disk.Reads >= 100) + { + var level = read > 20 ? AssessmentLevel.Act : read > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok; + worst = Max(worst, level); + parts.Add($"læsning {N(read, 1)} ms"); + } + + if (disk.AverageWriteMs is { } write && disk.Writes >= 100) + { + var logOnly = disk.DataFiles == 0; + var level = logOnly + ? write > 15 ? AssessmentLevel.Act : write > 5 ? AssessmentLevel.Note : AssessmentLevel.Ok + : write > 20 ? AssessmentLevel.Act : write > 10 ? AssessmentLevel.Note : AssessmentLevel.Ok; + worst = Max(worst, level); + parts.Add($"skrivning {N(write, 1)} ms{(logOnly ? " (log)" : "")}"); + } + + if (worst == AssessmentLevel.Unknown) + { + return new(AssessmentLevel.Unknown, "For lidt I/O i perioden til at vurdere."); + } + + var summary = $"{disk.Volume}: {string.Join(", ", parts)}."; + return worst switch + { + AssessmentLevel.Act => new(worst, summary + " Brugerne mærker det.", + "Tjek om disken er overbelastet (mange forespørgsler, der læser meget) eller langsom (storage, SAN, virtuel disk). Find de forespørgsler, der læser mest, før der købes hurtigere disk.", + "analyse"), + AssessmentLevel.Note => new(worst, summary + " Lidt højt.", "Hold øje med, om det følger bestemte tidspunkter (backup, indexvedligehold, batchjob).", "analyse"), + _ => new(worst, summary + " Godt."), + }; + } + + private static Assessment Cpu(IReadOnlyList sql, IReadOnlyList? other) + { + IReadOnlyList total = other is { Count: > 0 } ? Combine(sql, other) : sql; + var avg = Average(total); + var otherAvg = other is { Count: > 0 } ? Average(other) : 0; + var highRun = LongestRun(total, v => v > 80); + var summary = $"I gennemsnit {N(avg, 0)} % samlet CPU, højst {N(Peak(total), 0)} %" + + (other is { Count: > 0 } ? $"; heraf {N(Average(sql), 0)} % SQL Server og {N(otherAvg, 0)} % andre processer." : "."); + if (highRun >= Sustained) + { + return otherAvg >= 20 || otherAvg > Average(sql) + ? new(AssessmentLevel.Act, summary + " CPU'en er vedvarende over 80 %, og en stor del bruges af andre processer.", + "Find hvilke programmer på serveren der bruger CPU'en (Jobliste/Ressourceovervågning): antivirus uden undtagelser for databasefiler, backup-agenter eller en AOS på samme server.") + : new(AssessmentLevel.Act, summary + " SQL Server bruger vedvarende over 80 % af CPU'en.", + "Find forespørgslerne med højest CPU i analysen af tidsrummet og optimér dem (indexes, implicitte konverteringer); tjek MAXDOP og cost threshold for parallelism.", + "analyse?sortering=cpu"); + } + + return avg > 60 + ? new(AssessmentLevel.Note, summary + " Højt, men ikke vedvarende over 80 %.", "Hold øje med spidsbelastninger, og se hvilke forespørgsler der bruger CPU'en.", "analyse?sortering=cpu") + : new(AssessmentLevel.Ok, summary); + } + + private static Assessment CpuOther(IReadOnlyList other, IReadOnlyList? sql) + { + var avg = Average(other); + var summary = $"Andre processer brugte i gennemsnit {N(avg, 0)} % af CPU'en, højst {N(Peak(other), 0)} %."; + if (LongestRun(other, v => v > 30) >= Sustained || avg >= 20 && (sql is not { Count: > 0 } || avg > Average(sql))) + { + return new(AssessmentLevel.Act, summary + " Det er meget for en databaseserver.", + "Find programmet på serveren (Jobliste/Ressourceovervågning). Typisk antivirus uden undtagelser for .mdf/.ldf, backup-software eller en applikation på samme maskine."); + } + + return avg >= 10 + ? new(AssessmentLevel.Note, summary, "Undersøg hvilke programmer der kører på serveren ud over SQL Server.") + : new(AssessmentLevel.Ok, summary); + } + + private static Assessment Ple(IReadOnlyList points, MetricContext context) + { + var threshold = Math.Max(PleSecondsPer4Gb, PleSecondsPer4Gb * (context.BufferPoolGb ?? 4) / 4); + var avg = Average(points); + var min = points.Min(p => p.Value); + var median = Median(points); + var dip = median > 0 && min < median * 0.5; + var memory = context.MaxServerMemorySet == false + ? " max server memory er ikke sat, så SQL Server kan tage hukommelse fra Windows og andre programmer; sæt den (se konfigurationen)." + : ""; + var summary = $"I gennemsnit {N(avg, 0)} s, laveste {N(min, 0)} s. Grænsen er {N(threshold, 0)} s" + + (context.BufferPoolGb is { } gb ? $" ({N(PleSecondsPer4Gb, 0)} s pr. 4 GB af {N(gb, 0)} GB buffer pool)." : " (300 s pr. 4 GB buffer pool)."); + if (avg < threshold) + { + return new(AssessmentLevel.Act, summary + " Data bliver ikke længe i hukommelsen, så der læses meget fra disk." + memory, + "Find forespørgslerne, der læser mest (analysen sorteret efter læsninger), og giv dem bedre indexes; overvej mere hukommelse, når de er effektive.", + "analyse?sortering=laesninger"); + } + + if (dip || min < threshold) + { + return new(AssessmentLevel.Note, summary + $" Faldt{(dip ? " med mere end 50 %" : "")} undervejs: en forespørgsel har læst store mængder data og skubbet andet ud af hukommelsen." + memory, + "Find tidspunktet for faldet i grafen og se, hvilke forespørgsler der læste mest i det tidsrum.", "analyse?sortering=laesninger"); + } + + return memory.Length > 0 + ? new(AssessmentLevel.Note, summary + memory, "Sæt max server memory.", "konfiguration") + : new(AssessmentLevel.Ok, summary + " Godt."); + } + + private static Assessment Compilations(IReadOnlyList compilations, IReadOnlyList? batches) + { + var avg = Average(compilations); + if (batches is not { Count: > 0 } || Average(batches) <= 0) + { + return new(AssessmentLevel.Unknown, $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund; batch requests mangler til sammenligning."); + } + + var ratio = avg / Average(batches); + var summary = $"I gennemsnit {N(avg, 1)} kompileringer pr. sekund = {N(100 * ratio, 0)} % af batches."; + if (Average(batches) < 5) + { + return new(AssessmentLevel.Ok, summary + " Så lidt arbejde, at andelen ikke betyder noget."); + } + + return ratio switch + { + > 0.2 => new(AssessmentLevel.Act, summary + " Mange forespørgsler får en ny plan hver gang, hvilket koster CPU.", + "Find ad hoc-forespørgsler med literaler i plan cache; brug parametre, slå 'optimize for ad hoc workloads' til, og overvej forced parameterization for databasen. I AX: undgå 'Use literals'.", + "ventetyper/RESOURCE_SEMAPHORE_QUERY_COMPILE"), + > 0.1 => new(AssessmentLevel.Note, summary + " Lidt højt.", "Hold øje med CPU; se efter forespørgsler med literaler."), + _ => new(AssessmentLevel.Ok, summary + " Planer genbruges fint."), + }; + } + + private static Assessment SustainedCount(IReadOnlyList points, string none, string what, string action, string link) + { + var run = LongestRun(points, v => v > 0); + var peak = Peak(points); + if (run == 0) + { + return new(AssessmentLevel.Ok, none); + } + + var summary = $"{N(peak, 0)} {what} (højst); over 0 i {points.Count(p => p.Value > 0)} af {points.Count} målinger."; + return run >= Sustained + ? new(AssessmentLevel.Act, summary + " Det varede ved.", action, link) + : new(AssessmentLevel.Note, summary + " Kortvarigt.", action, link); + } + + private static Assessment Connections(IReadOnlyList points) + { + var avg = Average(points); + var peak = Peak(points); + var summary = $"I gennemsnit {N(avg, 0)} forbindelser, højst {N(peak, 0)}."; + return avg >= 10 && peak > 2 * avg + ? new(AssessmentLevel.Note, summary + " Antallet steg kraftigt undervejs.", "Se om det skyldes flere brugere, eller sessioner der hænger (blocking) eller ikke lukkes.", "analyse#blocking") + : new(AssessmentLevel.Ok, summary); + } + + private static List Combine(IReadOnlyList a, IReadOnlyList b) + { + var other = b.GroupBy(p => p.At).ToDictionary(g => g.Key, g => g.First().Value); + return a.Select(p => p with { Value = p.Value + (other.TryGetValue(p.At, out var v) ? v : 0) }).ToList(); + } + + private static int LongestRun(IReadOnlyList points, Func predicate) + { + int best = 0, run = 0; + foreach (var p in points.OrderBy(p => p.At)) + { + run = predicate(p.Value) ? run + 1 : 0; + best = Math.Max(best, run); + } + + return best; + } + + private static double Average(IReadOnlyList points) => points.Count == 0 ? 0 : points.Average(p => p.Value); + + private static double Peak(IReadOnlyList points) => points.Count == 0 ? 0 : points.Max(p => p.Value); + + private static double Median(IReadOnlyList points) + { + var sorted = points.Select(p => p.Value).Order().ToList(); + return sorted.Count == 0 ? 0 : sorted.Count % 2 == 1 ? sorted[sorted.Count / 2] : (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2; + } + + private static AssessmentLevel Max(AssessmentLevel a, AssessmentLevel b) => (AssessmentLevel)Math.Max((int)a, (int)b); + + private static string N(double value, int decimals) => value.ToString("N" + decimals, Danish); +} diff --git a/src/SQLInsight.Reports/TimeWindowAnalysis.cs b/src/SQLInsight.Reports/TimeWindowAnalysis.cs index 5cbba61..3af22c6 100644 --- a/src/SQLInsight.Reports/TimeWindowAnalysis.cs +++ b/src/SQLInsight.Reports/TimeWindowAnalysis.cs @@ -28,6 +28,19 @@ public sealed record TimeWindowAnalysis /// The heaviest statements. Their text is customer data: only for the local UI and PDF. public required IReadOnlyList TopQueries { get; init; } + /// What the assessment of the measurements needs besides them (buffer pool size, max server memory). + public MetricContext Context { get; init; } = new(); + + /// The assessment of a measurement in the window (OK, Bemærk, Reagér). + public Assessment Assess(MetricComparison metric) + { + ArgumentNullException.ThrowIfNull(metric); + return MetricAssessment.Assess(metric.Metric, metric.Points, Context); + } + + private static IReadOnlyList Points(List metrics, string metric) => + metrics.FirstOrDefault(m => m.Metric == metric)?.Points ?? []; + public bool HasData => Metrics.Any(m => m.Points.Count > 0) || Waits.Count > 0 || Files.Count > 0 || Disks.Count > 0 || Blocking.Count > 0 || Deadlocks.Count > 0 || TopQueries.Count > 0; @@ -103,6 +116,10 @@ public sealed record TimeWindowAnalysis Resolution = resolution, RawDays = rawDays, Metrics = metrics, + Context = MetricContext.From( + await repository.GetSnapshotAsync(id, "configuration", cancellationToken).ConfigureAwait(false), + await repository.GetSnapshotAsync(id, "server", cancellationToken).ConfigureAwait(false), + Points(metrics, Repository.Metrics.BatchRequestsPerSecond), Points(metrics, Repository.Metrics.CpuSqlServer), Points(metrics, Repository.Metrics.CpuOther)), Waits = WindowBaseline.CompareWaits(windowWaits, minutes, earlierWaits), Files = await repository.GetFileLatencyAsync(id, from, to, cancellationToken).ConfigureAwait(false), Disks = WindowBaseline.CompareDisks(windowDisks, earlierDisks), diff --git a/src/SQLInsight.Reports/TimeWindowReport.cs b/src/SQLInsight.Reports/TimeWindowReport.cs index 143d410..37697b6 100644 --- a/src/SQLInsight.Reports/TimeWindowReport.cs +++ b/src/SQLInsight.Reports/TimeWindowReport.cs @@ -99,6 +99,22 @@ public static class TimeWindowReport Row(table, m.IsUnusual, m.Label, Number(m.Average, m.Decimals) + m.Unit, Number(m.Peak, m.Decimals) + m.Unit, m.NormalAverage is { } n ? Number(n, m.Decimals) + m.Unit : "–", m.IsUnusual ? "afviger" : ""); } + + section.AddParagraph("Vurdering", StyleNames.Heading2); + Assessments(section, analysis.Metrics.Where(m => m.Points.Count > 0).Select(m => (m.Label, analysis.Assess(m)))); + } + + /// The assessment of each measurement (OK, Bemærk, Reagér) with the next step, as on the web page. + private static void Assessments(Section section, IEnumerable<(string Label, Assessment Assessment)> items) + { + foreach (var (label, assessment) in items.Where(i => i.Assessment.Level != AssessmentLevel.Unknown)) + { + var paragraph = section.AddParagraph(); + var level = paragraph.AddFormattedText($"{assessment.LevelText} ", TextFormat.Bold); + level.Color = assessment.Level switch { AssessmentLevel.Act => Unusual, AssessmentLevel.Note => new Color(0xB5, 0x47, 0x08), _ => new Color(0x07, 0x94, 0x55) }; + paragraph.AddFormattedText(label + ": ", TextFormat.Bold); + paragraph.AddText(assessment.Summary + (assessment.NextStep is { } next ? " Næste skridt: " + next : "")); + } } private static void Waits(Section section, TimeWindowAnalysis analysis) @@ -164,6 +180,7 @@ public static class TimeWindowReport } section.AddParagraph("Latens i parentes: det normale for samme klokkeslæt de foregående uger.", "Muted"); + Assessments(section, analysis.Disks.Select(d => (d.Window.Volume, MetricAssessment.AssessDisk(d.Window)))); static string Normal(double? value) => value is { } v ? $" ({v.ToString("N1", Danish)})" : ""; } diff --git a/src/SQLInsight.Web/Components/Pages/Analysis.razor b/src/SQLInsight.Web/Components/Pages/Analysis.razor index f1b5aae..7017973 100644 --- a/src/SQLInsight.Web/Components/Pages/Analysis.razor +++ b/src/SQLInsight.Web/Components/Pages/Analysis.razor @@ -58,6 +58,7 @@

Normalt @(m.NormalAverage is { } n ? Display.Number(n, m.Decimals) + m.Unit : "– (ingen historik)")@(m.IsUnusual ? " · afviger tydeligt" : "")

+
} @@ -117,7 +118,7 @@

Diskhastighed pr. drev

- +
diff --git a/src/SQLInsight.Web/Components/Pages/Instance.razor b/src/SQLInsight.Web/Components/Pages/Instance.razor index 601099b..6131629 100644 --- a/src/SQLInsight.Web/Components/Pages/Instance.razor +++ b/src/SQLInsight.Web/Components/Pages/Instance.razor @@ -47,10 +47,16 @@ Analysér et tidsrum
-
-
-
-
+ @foreach (var (metric, label, unit, decimals, _, max) in SQLInsight.Reports.TimeWindowAnalysis.MetricDefinitions) + { + var points = _metrics.GetValueOrDefault(metric) ?? []; +
+ + +
+ }
@@ -83,7 +89,7 @@ else

Diskhastighed pr. drev, seneste 24 timer

- + @if (_diskSeries.Count > 0) {
@@ -241,7 +247,8 @@ else private IReadOnlyList _resolved = []; private IReadOnlyList _notMeasurable = []; private IReadOnlyList _notApplicable = []; - private IReadOnlyList _cpuSql = [], _cpuOther = [], _batches = [], _ple = []; + private readonly Dictionary> _metrics = []; + private SQLInsight.Reports.MetricContext _context = new(); private IReadOnlyList _waits = []; private IReadOnlyList _files = []; private IReadOnlyList _disks = []; @@ -264,10 +271,15 @@ else var states = await Repository.GetRuleStatesAsync(id); _notMeasurable = states.Where(r => r.Outcome is RuleOutcome.NotMeasurable or RuleOutcome.Error).ToList(); _notApplicable = states.Where(r => r.Outcome == RuleOutcome.NotApplicable).ToList(); - _cpuSql = await Repository.GetMetricAsync(id, Metrics.CpuSqlServer, day); - _cpuOther = await Repository.GetMetricAsync(id, Metrics.CpuOther, day); - _batches = await Repository.GetMetricAsync(id, Metrics.BatchRequestsPerSecond, day); - _ple = await Repository.GetMetricAsync(id, Metrics.PageLifeExpectancy, day); + foreach (var definition in SQLInsight.Reports.TimeWindowAnalysis.MetricDefinitions) + { + _metrics[definition.Metric] = await Repository.GetMetricAsync(id, definition.Metric, day); + } + + _context = SQLInsight.Reports.MetricContext.From( + await Repository.GetSnapshotAsync(id, "configuration"), + await Repository.GetSnapshotAsync(id, "server"), + _metrics[Metrics.BatchRequestsPerSecond], _metrics[Metrics.CpuSqlServer], _metrics[Metrics.CpuOther]); _waits = await Repository.GetTopWaitsAsync(id, day, 12); _files = await Repository.GetFileLatencyAsync(id, day); _disks = (await Repository.GetDiskActivityAsync(id, day, DateTimeOffset.UtcNow)).Select(d => new SQLInsight.Reports.DiskComparison(d, null)).ToList(); diff --git a/src/SQLInsight.Web/Components/Shared/AssessmentNote.razor b/src/SQLInsight.Web/Components/Shared/AssessmentNote.razor new file mode 100644 index 0000000..60bd5d1 --- /dev/null +++ b/src/SQLInsight.Web/Components/Shared/AssessmentNote.razor @@ -0,0 +1,27 @@ +@* The assessment of a measurement and what it means. Links are relative to the instance page. *@ +
+ @Assessment.LevelText + @Assessment.Summary + @if (Assessment.NextStep is { } next) + { +
Næste skridt: @next @if (Assessment.Link is { } link && InstanceName is not null) { Se mere }
+ } + @if (Explanation is { } text) + { +
Hvad betyder det?

@text

+ } +
+ +@code { + [Parameter, EditorRequired] public Assessment Assessment { get; set; } = default!; + [Parameter] public string? Explanation { get; set; } + [Parameter] public string? InstanceName { get; set; } + + private string CssClass => Assessment.Level switch + { + AssessmentLevel.Ok => "ok", + AssessmentLevel.Note => "note", + AssessmentLevel.Act => "act", + _ => "", + }; +} diff --git a/src/SQLInsight.Web/Components/Shared/DiskTable.razor b/src/SQLInsight.Web/Components/Shared/DiskTable.razor index 80eae69..f25c69f 100644 --- a/src/SQLInsight.Web/Components/Shared/DiskTable.razor +++ b/src/SQLInsight.Web/Components/Shared/DiskTable.razor @@ -9,7 +9,7 @@ else DrevFilerLæs IOPSSkriv IOPS - Læs MB/sSkriv MB/sGns. læsGns. skriv + Læs MB/sSkriv MB/sGns. læsGns. skrivVurdering @@ -25,10 +25,12 @@ else @Display.Number(w.WriteMbPerSecond ?? 0, 1) @Ms(w.AverageReadMs)@Normal(d.Normal?.AverageReadMs) @Ms(w.AverageWriteMs)@Normal(d.Normal?.AverageWriteMs) + } +
Hvad betyder det?

@SQLInsight.Reports.MetricAssessment.Explanation("disk.read")

@SQLInsight.Reports.MetricAssessment.Explanation("disk.write.log")

@SQLInsight.Reports.MetricAssessment.Explanation("disk.write")

Latens er gennemsnitstiden pr. læsning/skrivning set fra SQL Server (sys.dm_io_virtual_file_stats). Datafiler bør ligge under 10-20 ms, logfiler under 5 ms.@(ShowNormal ? " I parentes: det normale for samme klokkeslæt de foregående uger." : "")

} @@ -36,6 +38,7 @@ else [Parameter, EditorRequired] public IReadOnlyList Disks { get; set; } = []; [Parameter] public string Empty { get; set; } = "Ingen målinger pr. drev endnu."; [Parameter] public bool ShowNormal { get; set; } + [Parameter] public string? InstanceName { get; set; } private static string Ms(double? value) => value is { } v ? $"{Display.Number(v, 1)} ms" : "–"; diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index 2af74b0..d2fc121 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -196,3 +196,11 @@ pre.sql { white-space: pre-wrap; word-break: break-word; max-height: 9rem; overf details.not-applicable { margin-top: 1rem; } details.not-applicable summary { cursor: pointer; color: var(--text-2); font-weight: 600; } details.not-applicable > table { margin-top: .75rem; } + +/* Assessment of a measurement: OK, Bemærk, Reagér. */ +.assess { display: inline-block; padding: .05rem .5rem; border-radius: 999px; font-size: .75rem; font-weight: 600; color: var(--c, var(--info)); background: var(--c-soft, var(--info-soft)); } +.assess.ok { --c: var(--ok); --c-soft: var(--ok-soft); } +.assess.note { --c: var(--medium); --c-soft: var(--medium-soft); } +.assess.act { --c: var(--critical); --c-soft: var(--critical-soft); } +.assessment { margin: .4rem 0 0; font-size: .85rem; } +.assessment details { border-top: 0; padding: .3rem 0 0; } diff --git a/tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs b/tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs new file mode 100644 index 0000000..2da7f22 --- /dev/null +++ b/tests/SQLInsight.Agent.Tests/MetricAssessmentTests.cs @@ -0,0 +1,107 @@ +using SQLInsight.Core.Data; +using SQLInsight.Reports; +using SQLInsight.Repository; + +namespace SQLInsight.Agent.Tests; + +public class MetricAssessmentTests +{ + private static readonly DateTime Start = new(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc); + + private static List Series(params double[] values) => + values.Select((v, i) => new MetricPoint(Start.AddMinutes(5 * i), v)).ToList(); + + [Fact] + public void Ple_threshold_scales_with_the_buffer_pool() + { + var ple = Series(1000, 1100, 1050, 1000); + + Assert.Equal(AssessmentLevel.Ok, MetricAssessment.Assess(Metrics.PageLifeExpectancy, ple, new MetricContext(BufferPoolGb: 8, MaxServerMemorySet: true)).Level); + var big = MetricAssessment.Assess(Metrics.PageLifeExpectancy, ple, new MetricContext(BufferPoolGb: 64, MaxServerMemorySet: true)); // 4800 s + Assert.Equal(AssessmentLevel.Act, big.Level); + Assert.Contains("4.800", big.Summary, StringComparison.Ordinal); + } + + [Fact] + public void Ple_dips_of_more_than_half_are_noted_and_unset_max_server_memory_is_mentioned() + { + var dip = MetricAssessment.Assess(Metrics.PageLifeExpectancy, Series(3000, 3100, 900, 3000, 3050), new MetricContext(BufferPoolGb: 4, MaxServerMemorySet: true)); + var unset = MetricAssessment.Assess(Metrics.PageLifeExpectancy, Series(3000, 3100), new MetricContext(BufferPoolGb: 4, MaxServerMemorySet: false)); + + Assert.Equal(AssessmentLevel.Note, dip.Level); + Assert.Contains("50 %", dip.Summary, StringComparison.Ordinal); + Assert.Equal(AssessmentLevel.Note, unset.Level); + Assert.Contains("max server memory", unset.Summary, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1, AssessmentLevel.Ok)] + [InlineData(15, AssessmentLevel.Note)] + [InlineData(30, AssessmentLevel.Act)] + public void Compilations_are_judged_against_batches(double compilations, AssessmentLevel expected) + { + var context = new MetricContext(BatchRequests: Series(100, 100, 100)); + + Assert.Equal(expected, MetricAssessment.Assess(Metrics.CompilationsPerSecond, Series(compilations, compilations, compilations), context).Level); + } + + [Fact] + public void Counts_above_zero_must_be_sustained_to_act() + { + Assert.Equal(AssessmentLevel.Ok, MetricAssessment.Assess(Metrics.MemoryGrantsPending, Series(0, 0, 0)).Level); + Assert.Equal(AssessmentLevel.Note, MetricAssessment.Assess(Metrics.MemoryGrantsPending, Series(0, 2, 0, 0)).Level); + var blocked = MetricAssessment.Assess(Metrics.ProcessesBlocked, Series(0, 3, 4, 5, 0)); + Assert.Equal(AssessmentLevel.Act, blocked.Level); + Assert.Equal("analyse#blocking", blocked.Link); + } + + [Fact] + public void High_cpu_from_other_processes_points_at_them() + { + var sql = Series(30, 35, 30, 32); + var other = Series(55, 55, 60, 58); + + var cpu = MetricAssessment.Assess(Metrics.CpuSqlServer, sql, new MetricContext(CpuOther: other)); + var own = MetricAssessment.Assess(Metrics.CpuSqlServer, Series(90, 92, 95, 91), new MetricContext(CpuOther: Series(2, 2, 2, 2))); + + Assert.Equal(AssessmentLevel.Act, cpu.Level); + Assert.Contains("andre processer", cpu.Summary, StringComparison.Ordinal); + Assert.Equal(AssessmentLevel.Act, own.Level); + Assert.Contains("SQL Server bruger", own.Summary, StringComparison.Ordinal); + Assert.Equal(AssessmentLevel.Ok, MetricAssessment.Assess(Metrics.CpuSqlServer, Series(20, 25), new MetricContext(CpuOther: Series(1, 1))).Level); + } + + [Theory] + [InlineData(5, 3, 1, AssessmentLevel.Ok)] + [InlineData(15, 3, 1, AssessmentLevel.Note)] + [InlineData(30, 3, 1, AssessmentLevel.Act)] + [InlineData(1, 8, 0, AssessmentLevel.Note)] // log drive: 8 ms per write + [InlineData(1, 20, 0, AssessmentLevel.Act)] + public void Disk_latency_thresholds(double readMs, double writeMs, int dataFiles, AssessmentLevel expected) + { + var disk = new DiskActivity("L:", dataFiles, 1, 1000, 1000, 0, 0, (long)(readMs * 1000), (long)(writeMs * 1000), 300); + + Assert.Equal(expected, MetricAssessment.AssessDisk(disk).Level); + } + + [Fact] + public void Every_metric_has_an_explanation_and_no_data_is_unknown() + { + foreach (var (metric, _, _, _, _, _) in TimeWindowAnalysis.MetricDefinitions) + { + Assert.NotEqual("Ingen forklaring.", MetricAssessment.Explanation(metric)); + Assert.Equal(AssessmentLevel.Unknown, MetricAssessment.Assess(metric, []).Level); + } + } + + [Fact] + public void Context_uses_max_server_memory_capped_by_physical_memory() + { + var config = new ConfigurationSnapshot { CapturedAt = DateTimeOffset.UtcNow, Options = [new ConfigurationOption("max server memory (MB)", 28672, 28672)] }; + var server = new ServerSnapshot { CapturedAt = DateTimeOffset.UtcNow, LogicalCpuCount = 8, PhysicalMemoryMb = 32768, SqlServerStartTime = Start }; + var unset = config with { Options = [new ConfigurationOption("max server memory (MB)", 2147483647, 2147483647)] }; + + Assert.Equal(28, MetricContext.From(config, server).BufferPoolGb); + Assert.Equal((32.0, false), (MetricContext.From(unset, server).BufferPoolGb!.Value, MetricContext.From(unset, server).MaxServerMemorySet!.Value)); + } +} diff --git a/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs index 19348b8..24ac547 100644 --- a/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs +++ b/tests/SQLInsight.IntegrationTests/TimeWindowAnalysisTests.cs @@ -121,6 +121,7 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime Assert.Equal(90, cpu.Average); Assert.Equal(30, cpu.NormalAverage); Assert.True(cpu.IsUnusual); + Assert.Equal(AssessmentLevel.Act, analysis.Assess(cpu).Level); Assert.Equal("SELECT * FROM SALESLINE", Assert.Single(analysis.TopQueries).Text); Assert.Single(analysis.Blocking); } @@ -148,6 +149,8 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime Assert.Contains("LCK_M_X", html, StringComparison.Ordinal); Assert.Contains("Diskhastighed pr. drev", html, StringComparison.Ordinal); Assert.Contains("class=\"unusual\"", html, StringComparison.Ordinal); + Assert.Contains("Hvad betyder det?", html, StringComparison.Ordinal); + Assert.Contains("class=\"assess act\"", html, StringComparison.Ordinal); // SQL Server CPU 90 % 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))}"); @@ -158,6 +161,8 @@ public class TimeWindowAnalysisTests(SqlServerFixture sql) : IAsyncLifetime var instancePage = await client.GetStringAsync($"/instanser/{name}"); Assert.Contains($"/instanser/{name}/analyse", instancePage, StringComparison.Ordinal); + Assert.Contains("Hvad betyder det?", instancePage, StringComparison.Ordinal); + Assert.Contains("Memory grants pending", 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);