diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a9a821..4a599e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`cpu_attribution` on the top-CPU rankings: what fraction of the box the ranking explains** ([#2320]) - the last unshipped item from #2235's wishlist. `get_top_queries_by_cpu` and `get_top_procedures_by_cpu` (both SKUs) now return the returned rows' summed CPU-seconds, the SQL process's measured CPU-seconds for the same window (avg `cpu_utilization` % x core count x window - both stores already collect every piece), and `attributed_cpu_ratio`. Pre-#2290 the reads explained ~10% of the box and nothing said so - a caller chased the visible tenth assuming it was everything; and the ratio catches impossible claims at a glance (an external comparison died the moment its worker_time sum divided out to 137% of the box's available CPU-seconds - above the process's own measured consumption the note now says to distrust the numbers rather than presenting them). Below half, a note explains where unattributable CPU goes (evictions between snapshots, rows outside the top-N, zero-cost rows, non-query CPU). The degrade rule is explicit: missing CPU series, missing core count, or a series covering under 90% of the window omits the ratio rather than inventing one. One computation in `PerformanceMonitor.Common` (`CpuAttribution`), pinned by the same decision table in both test projects; the denominator read windows on `collection_time` with the same bounds as the rankings, so numerator and denominator share collection gaps. - **`get_query_store_health`: the MCP read for the new collector, both SKUs** ([#2319]) - the promised follow-up to the `query_store_health` collector: one browsable tool (beside `get_database_scoped_config`, whose latest-snapshot shape it mirrors) returning per-database actual vs desired state with the mismatch pre-folded into `state_matches_desired`, `readonly_reason` both raw and decoded, storage used vs cap with `pct_of_cap`, cleanup mode/thresholds, and the runtime-stats interval length; also exposed as a `/api/read` web endpoint. The `readonly_reason` bit table now lives ONCE in `PerformanceMonitor.Common` (`QueryStoreReadonlyReason`) and both viewers' grids and both MCP servers decode through it - the labels were miswritten from memory once during #2319 review, so a single source is the fix. While counting the tools for the instructions doc, the census sentence turned out to have silently drifted (it said ninety tools while the server exposed one hundred); it is rewritten with accurate digit counts (101 total / 76 shared with Lite / 25 Darling-only) and a new cross-app pin test parses it against the scanned inventory so it can never drift again. - **Per-database Query Store health: a new `query_store_health` collector, both SKUs, both stores** ([#2319]) - `database_config` knows exactly one bit (`is_query_store_on = true`), which cannot answer the questions an investigation like #2312 needed: is Query Store actually WORKING (the classic silent failure is desired_state READ_WRITE with actual_state READ_ONLY after the storage cap hit - `readonly_reason` says why), how close to the cap is it, and what interval grain is it aggregating at. The new collector reads `sys.database_query_store_options` per database - the same proven enumeration idiom as `database_scoped_config` (list accessible ONLINE primaries, then `[db].sys.sp_executesql` per database), deliberately NOT filtered to QS-on databases: the options view answers one row even when Query Store is off, so OFF is recorded as OFF and an absent row can only mean "not collected". Hourly rather than the config family's on-load cadence, because unlike operator-changed knobs these values change BY THEMSELVES and the cap-hit transition is the whole point of collecting them. Every column exists on 2016+, so there are no version gates. Surfaced as a Query Store sub-tab on the Configuration tab in both apps (V76 store table + `v_query_store_health` passthrough keep the two viewers' SQL byte-identical; Lite's table and archive view generate from the catalog); a `get_query_store_health` MCP read follows separately. The issue asked for the fields on `database_config` itself; they land as a sibling enumerating collector instead because `database_config` is a single `sys.databases` scan and these fields need per-database context - bolting an enumeration onto it would change its execution model and failure isolation, and the codebase already has the per-database config member in `database_scoped_config` to mirror. - **V75 gives plan CONTENT its own retention horizon, because the fact-coupled one cannot bound a young store** ([#2316]) - the payload dimensions' GC deliberately follows the widest dim-feeding fact retention (90 days) so nothing a live fact references is ever deleted, and that guarantee has a blind spot measured on the dogfood fleet: `query_plan_dim` reached **127 GB - 63% of the store - in its first 22 days**, growing ~6 GB/day of parameter-sniffing recompile churn (344k distinct plan XMLs per day from 5,327 plan SHAPES - 65 variants per shape, the worst single shape producing 57,402 in one day), with the coupled GC unable to delete a single row until the horizon crossed the dimension's birth date - roughly a month AFTER the projected disk-full. Orphan pruning already existed and was healthy; compression was already spent (every row app-gzipped); the inflow is legitimate distinct content by the #1767 design, so the remaining lever is lifetime. The new `config_service.plan_content_retention_days` knob (default 21, clamps [7,365], 0 = disabled = the old behavior byte-for-byte) sets how long a stored plan XML outlives its last sighting: the dimension cutoff becomes the NEWER of the fact-coupled cutoff and `now - (knob + 1)` - the same one-day margin as the measured floor, for the same hourly `last_seen` refresh guard. Facts keep their full retention (metrics, hashes and text stay analyzable); a plan older than the window renders as the missing plan every reader already handles. The horizon governs the PLAN dimension only - query text keeps the fact-coupled cutoff (it is ~40 MB against the plan dim's 127 GB, and shortening it would break "text stays analyzable" for nothing) - and the Query Store plan map's prune learns the knob too, keeping the dimension-outlives-the-map ordering under every knob value so a live map row can never resolve to deleted content. A knob wider than the fact horizon is deliberately a no-op - it must not become a way to keep XML nothing can reference. Deliberately NOT done: shape-keyed latest-wins storage would shrink this 65x but breaks the historical-fact-to-exact-XML contract #1767 preserves on purpose - parameter-variant plans are the product's diagnostic bread and butter. @@ -2797,6 +2798,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#2246]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2246 [#2319]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2319 [#2317]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2317 +[#2320]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2320 [#2316]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2316 [#2324]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2324 [#2300]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2300 diff --git a/Darling/Darling.Tests/CpuAttributionTests.cs b/Darling/Darling.Tests/CpuAttributionTests.cs new file mode 100644 index 00000000..baa732b0 --- /dev/null +++ b/Darling/Darling.Tests/CpuAttributionTests.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// +/// Decision-table pins for the shared (#2320) — the attributed-CPU +/// disclosure both SKUs' get_top_queries_by_cpu / get_top_procedures_by_cpu serve. The contract under +/// pin: the ratio is measured-or-omitted (never invented — missing samples, missing core count, or +/// thin coverage all degrade to null + a reason), the low note fires under half, and above the +/// process's own measured CPU the note calls the number impossible rather than presenting it — +/// the 137%-of-the-box claim is the whole reason the marker exists. This SAME table is pinned +/// identically in Lite.Tests so the two SKUs cannot drift. +/// +public sealed class CpuAttributionTests +{ + private static readonly DateTime Start = new(2026, 8, 18, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime End = Start.AddHours(1); + + /// Full coverage, healthy ratio: 25% of 8 cores over an hour = 7,200 CPU-seconds; + /// 5,000 ranked seconds is 0.694 — present, rounded to 3, no note. + [Fact] + public void HealthyRatio_NoNote() + { + var result = CpuAttribution.Compute( + rankedCpuSeconds: 5000, Start, End, + sampleCount: 60, firstSampleUtc: Start, lastSampleUtc: End, avgSqlCpuPercent: 25, cpuCount: 8); + + Assert.Equal(5000, result.RankedCpuSeconds); + Assert.Equal(7200, result.SqlCpuSecondsInWindow); + Assert.Equal(0.694, result.AttributedCpuRatio); + Assert.Null(result.Note); + } + + /// The pre-#2290 shape this feature exists for: the ranking explains ~10% of the box, + /// and now something says so instead of letting the caller chase the visible tenth. + [Fact] + public void LowRatio_SaysNotTheWholeStory() + { + var result = CpuAttribution.Compute(720, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(0.1, result.AttributedCpuRatio); + Assert.NotNull(result.Note); + Assert.Contains("10%", result.Note, StringComparison.Ordinal); + Assert.Contains("not the whole story", result.Note, StringComparison.Ordinal); + } + + /// The 137% case — worker_time summing to more CPU than the process consumed is an + /// impossible claim, and the note must say to distrust the numbers, not decorate them. + [Fact] + public void OverAttribution_IsFlaggedImpossible() + { + var result = CpuAttribution.Compute(9864, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(1.37, result.AttributedCpuRatio); + Assert.NotNull(result.Note); + Assert.Contains("137%", result.Note, StringComparison.Ordinal); + Assert.Contains("impossible-claim", result.Note, StringComparison.Ordinal); + } + + /// Just above 1.0 is sampling noise between two independent series, not a lie — + /// the impossible flag waits for the slack threshold. + [Fact] + public void SlightlyOverOne_CarriesNoNote() + { + var result = CpuAttribution.Compute(7500, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(1.042, result.AttributedCpuRatio); + Assert.Null(result.Note); + } + + [Fact] + public void NoSamples_OmitsRatio_AndSaysWhy() + { + var result = CpuAttribution.Compute(5000, Start, End, 0, null, null, null, 8); + + Assert.Equal(5000, result.RankedCpuSeconds); + Assert.Null(result.SqlCpuSecondsInWindow); + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("no cpu_utilization samples", result.Note, StringComparison.Ordinal); + } + + [Fact] + public void NoCoreCount_OmitsRatio_AndSaysWhy() + { + var result = CpuAttribution.Compute(5000, Start, End, 60, Start, End, 25, cpuCount: 0); + + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("core count unavailable", result.Note, StringComparison.Ordinal); + } + + /// #2320's explicit degrade rule: a server whose CPU series starts mid-window (added, + /// or monitoring resumed) would deflate the denominator and inflate the ratio — omit instead. + [Fact] + public void PartialCoverage_OmitsRatio_WithThePercentage() + { + var result = CpuAttribution.Compute(5000, Start, End, 30, Start.AddMinutes(30), End, 25, 8); + + Assert.Null(result.AttributedCpuRatio); + Assert.NotNull(result.Note); + Assert.Contains("50%", result.Note, StringComparison.Ordinal); + Assert.Contains("partial denominator", result.Note, StringComparison.Ordinal); + } + + /// Samples straddling the window edges clamp to full coverage — a series wider than the + /// window is the NORMAL case (the store holds more history than any one read). + [Fact] + public void SamplesBeyondTheWindow_ClampToFullCoverage() + { + var result = CpuAttribution.Compute( + 5000, Start, End, 120, Start.AddHours(-1), End.AddHours(1), 25, 8); + + Assert.Equal(0.694, result.AttributedCpuRatio); + } + + /// An idle box measures zero CPU-seconds; a ratio against zero is undefined, and the + /// measured zero is still reported so the caller sees WHY. + [Fact] + public void ZeroMeasuredCpu_OmitsRatio_ReportsTheZero() + { + var result = CpuAttribution.Compute(5000, Start, End, 60, Start, End, avgSqlCpuPercent: 0, cpuCount: 8); + + Assert.Equal(0, result.SqlCpuSecondsInWindow); + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("zero", result.Note, StringComparison.Ordinal); + } + + [Fact] + public void EmptyWindow_OmitsRatio() + { + var result = CpuAttribution.Compute(5000, Start, Start, 60, Start, End, 25, 8); + + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("window is empty", result.Note, StringComparison.Ordinal); + } + + /// The numerator is rounded for emission but the ratio divides the RAW value — rounding + /// before dividing would move the third decimal on big windows. + [Fact] + public void RankedSecondsRoundToOneDecimal_RatioToThree() + { + var result = CpuAttribution.Compute(1234.5678, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(1234.6, result.RankedCpuSeconds); + Assert.Equal(0.171, result.AttributedCpuRatio); + } +} diff --git a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs index 0be0d66e..297830c0 100644 --- a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs +++ b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs @@ -191,6 +191,21 @@ public void CpuSql_ReadsBaseTable_DeSkewsSampleTime_WindowsOnCollectionTime() Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); /* window on the reliable clock */ } + /// #2320: the attribution denominator windows on collection_time — the SAME bounds the + /// rankings use, so numerator and denominator share collection gaps — and aggregates rather than + /// pulling sample rows. + [Fact] + public void CpuWindowAggregateSql_WindowsOnCollectionTime_BothEdges() + { + var sql = DarlingDataReader.CpuWindowAggregateSql; + Assert.Contains("FROM cpu_utilization_stats", sql, StringComparison.Ordinal); + Assert.Contains("AVG(sqlserver_cpu_utilization)", sql, StringComparison.Ordinal); + Assert.Contains("MIN(collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("MAX(collection_time)", sql, StringComparison.Ordinal); + Assert.Contains("collection_time >= $2", sql, StringComparison.Ordinal); + Assert.Contains("collection_time <= $3", sql, StringComparison.Ordinal); + } + [Fact] public void WaitStatsSql_AggregatesDeltas_HeaviestFirst() { @@ -377,6 +392,7 @@ exactly like the viewer's UTC-offset read. */ [InlineData(nameof(DarlingDataReader.ServerListSql))] [InlineData(nameof(DarlingDataReader.CollectionHealthSql))] [InlineData(nameof(DarlingDataReader.LatestServerPropertiesSql))] + [InlineData(nameof(DarlingDataReader.CpuWindowAggregateSql))] public void Reads_ArePostgresDialect_NoTsqlIsms(string sqlName) { var sql = SqlByName(sqlName); @@ -405,6 +421,7 @@ public void Reads_ArePostgresDialect_NoTsqlIsms(string sqlName) nameof(DarlingDataReader.QueryStoreTopSql) => DarlingDataReader.QueryStoreTopSql, nameof(DarlingDataReader.ServerListSql) => DarlingDataReader.ServerListSql, nameof(DarlingDataReader.CollectionHealthSql) => DarlingDataReader.CollectionHealthSql, + nameof(DarlingDataReader.CpuWindowAggregateSql) => DarlingDataReader.CpuWindowAggregateSql, _ => DarlingDataReader.LatestServerPropertiesSql, }; diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 79992acf..fd265bee 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -174,6 +174,46 @@ public static async Task> GetCpuUtilizationAsync( return samples; } + public sealed record CpuWindowAggregate(int SampleCount, DateTime? FirstSample, DateTime? LastSample, double? AvgSqlCpuPercent); + + /// + /// The attributed-CPU denominator's pieces (#2320): sample count, coverage bounds, and average SQL + /// CPU% over the window. Windowed on collection_time — the SAME bounds the top-queries/procedures + /// rankings use — so numerator and denominator share collection gaps; sample_time skew is irrelevant + /// to an average. $1 server_id, $2/$3 window (naive UTC). + /// + public const string CpuWindowAggregateSql = """ + SELECT + COUNT(*), + MIN(collection_time), + MAX(collection_time), + AVG(sqlserver_cpu_utilization)::double precision + FROM cpu_utilization_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND collection_time <= $3 + """; + + public static async Task GetCpuWindowAggregateAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default) + { + await using var command = postgres.CreateCommand(CpuWindowAggregateSql); + AddInt(command, serverId); + AddTimestamp(command, startUtc); + AddTimestamp(command, endUtc); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + if (!await reader.ReadAsync(cancellationToken)) + { + return new CpuWindowAggregate(0, null, null, null); + } + + return new CpuWindowAggregate( + reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0), System.Globalization.CultureInfo.InvariantCulture), + reader.IsDBNull(1) ? null : reader.GetDateTime(1), + reader.IsDBNull(2) ? null : reader.GetDateTime(2), + reader.IsDBNull(3) ? null : reader.GetDouble(3)); + } + /* ─────────────────────────── wait stats ─────────────────────────── */ /// diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index ea0dfffe..8cb1fdcf 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -430,7 +430,7 @@ public static async Task GetPerfmonStats( /* ═══════════════════════════ query performance ═══════════════════════════ */ - [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note.")] + [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means only rows predating the text dimension). Set group_by='host_object' to roll all of a procedure's statements into one row — necessary when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Supports database and parallelism filtering. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")] public static async Task GetTopQueriesByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -467,9 +467,24 @@ the exact wrong conclusion this option exists to prevent. */ if (rows.Count == 0) return McpHelpers.Status("unavailable", "No query stats available for the specified time range."); - IEnumerable filtered = rows; - if (parallel_only || min_dop > 1) - filtered = filtered.Where(r => r.MaxDop > 1 && r.MaxDop >= (min_dop > 1 ? min_dop : 2)); + var filtered = rows + .Where(r => !(parallel_only || min_dop > 1) || (r.MaxDop > 1 && r.MaxDop >= (min_dop > 1 ? min_dop : 2))) + .ToList(); + + /* #2320: what fraction of the box's measured CPU the RETURNED rows explain — numerator is + the caller-visible ranking (post top-N, post filters), denominator is measured, and the + ratio is omitted rather than invented when a denominator piece is missing. The two reads + are independent, so they run concurrently (review catch). */ + var cpuAggregateTask = DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); + var propertiesTask = DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId); + await Task.WhenAll(cpuAggregateTask, propertiesTask); + var cpuAggregate = await cpuAggregateTask; + var properties = await propertiesTask; + var attribution = CpuAttribution.Compute( + filtered.Sum(r => r.TotalCpuUs) / 1_000_000.0, + now.AddHours(-hours_back), now, + cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, + properties?.CpuCount ?? 0); var result = filtered.Select(r => new { @@ -529,6 +544,13 @@ the exact wrong conclusion this option exists to prevent. */ /* #2235: echoed so a stored or pasted payload cannot be misread as the other grouping — the two answer different questions and the rows look alike. */ group_by = rollUp ? "host_object" : "query_hash", + cpu_attribution = new + { + ranked_cpu_seconds = attribution.RankedCpuSeconds, + sql_cpu_seconds_in_window = attribution.SqlCpuSecondsInWindow, + attributed_cpu_ratio = attribution.AttributedCpuRatio, + note = attribution.Note + }, queries = result }, McpHelpers.JsonOptions); } @@ -538,7 +560,7 @@ the two answer different questions and the rows look alike. */ } } - [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time. Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note.")] + [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time. Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")] public static async Task GetTopProceduresByCpu( NpgsqlDataSource postgres, [Description("Server name or display name.")] string? server_name = null, @@ -563,6 +585,19 @@ public static async Task GetTopProceduresByCpu( "unavailable", "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); + /* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, + same concurrent independent reads. */ + var cpuAggregateTask = DarlingDataReader.GetCpuWindowAggregateAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now); + var propertiesTask = DarlingDataReader.GetLatestServerPropertiesAsync(postgres, resolved.ServerId); + await Task.WhenAll(cpuAggregateTask, propertiesTask); + var cpuAggregate = await cpuAggregateTask; + var properties = await propertiesTask; + var attribution = CpuAttribution.Compute( + rows.Sum(r => r.TotalCpuUs) / 1_000_000.0, + now.AddHours(-hours_back), now, + cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, + properties?.CpuCount ?? 0); + var result = rows.Select(r => new { database_name = r.DatabaseName, @@ -593,6 +628,13 @@ public static async Task GetTopProceduresByCpu( { server = resolved.ServerName, hours_back, + cpu_attribution = new + { + ranked_cpu_seconds = attribution.RankedCpuSeconds, + sql_cpu_seconds_in_window = attribution.SqlCpuSecondsInWindow, + attributed_cpu_ratio = attribution.AttributedCpuRatio, + note = attribution.Note + }, procedures = result }, McpHelpers.JsonOptions); } diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs index 362ab0b5..085d4f4a 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs @@ -81,8 +81,8 @@ internal static class DarlingMcpInstructions | `get_file_io_stats` | Latest per-file I/O: reads/writes/bytes/stall and computed read/write latency | `server_name` | | `get_tempdb_trend` | TempDB space over time (user / internal / version store / unallocated) + top consumer | `server_name`, `hours_back` (default 24) | | `get_perfmon_stats` | Latest perfmon counters (value + delta); filter by counter / instance | `server_name`, `counter_name`, `instance_name` | - | `get_top_queries_by_cpu` | Expensive queries from query stats (plan cache) with query_hash / sql_handle | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `parallel_only`, `min_dop` | - | `get_top_procedures_by_cpu` | Most expensive stored procedures by total CPU | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name` | + | `get_top_queries_by_cpu` | Expensive queries from query stats (plan cache) with query_hash / sql_handle; `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name`, `parallel_only`, `min_dop` | + | `get_top_procedures_by_cpu` | Most expensive stored procedures by total CPU, with the same `cpu_attribution` disclosure | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name` | | `get_query_store_top` | Expensive queries from Query Store with query_id / plan_id (survives restarts) | `server_name`, `hours_back` (default 24), `top` (default 20), `database_name` | | `list_servers` | All monitored servers with collection-freshness status and last collection time | none | | `get_collection_health` | Per-collector health (running / failing / stale) over the last 7 days, plus the server's sweep_pressure verdict (a SATURATED body collects at a multiple of its configured cadence with every collector healthy) | `server_name` | diff --git a/Lite.Tests/CpuAttributionTests.cs b/Lite.Tests/CpuAttributionTests.cs new file mode 100644 index 00000000..a6cc50ba --- /dev/null +++ b/Lite.Tests/CpuAttributionTests.cs @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using Xunit; + +namespace Lite.Tests; + +/// +/// Decision-table pins for the shared (#2320) — the attributed-CPU +/// disclosure both SKUs' get_top_queries_by_cpu / get_top_procedures_by_cpu serve. The contract under +/// pin: the ratio is measured-or-omitted (never invented — missing samples, missing core count, or +/// thin coverage all degrade to null + a reason), the low note fires under half, and above the +/// process's own measured CPU the note calls the number impossible rather than presenting it — +/// the 137%-of-the-box claim is the whole reason the marker exists. This SAME table is pinned +/// identically in Darling.Tests so the two SKUs cannot drift. +/// +public sealed class CpuAttributionTests +{ + private static readonly DateTime Start = new(2026, 8, 18, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime End = Start.AddHours(1); + + /// Full coverage, healthy ratio: 25% of 8 cores over an hour = 7,200 CPU-seconds; + /// 5,000 ranked seconds is 0.694 — present, rounded to 3, no note. + [Fact] + public void HealthyRatio_NoNote() + { + var result = CpuAttribution.Compute( + rankedCpuSeconds: 5000, Start, End, + sampleCount: 60, firstSampleUtc: Start, lastSampleUtc: End, avgSqlCpuPercent: 25, cpuCount: 8); + + Assert.Equal(5000, result.RankedCpuSeconds); + Assert.Equal(7200, result.SqlCpuSecondsInWindow); + Assert.Equal(0.694, result.AttributedCpuRatio); + Assert.Null(result.Note); + } + + /// The pre-#2290 shape this feature exists for: the ranking explains ~10% of the box, + /// and now something says so instead of letting the caller chase the visible tenth. + [Fact] + public void LowRatio_SaysNotTheWholeStory() + { + var result = CpuAttribution.Compute(720, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(0.1, result.AttributedCpuRatio); + Assert.NotNull(result.Note); + Assert.Contains("10%", result.Note, StringComparison.Ordinal); + Assert.Contains("not the whole story", result.Note, StringComparison.Ordinal); + } + + /// The 137% case — worker_time summing to more CPU than the process consumed is an + /// impossible claim, and the note must say to distrust the numbers, not decorate them. + [Fact] + public void OverAttribution_IsFlaggedImpossible() + { + var result = CpuAttribution.Compute(9864, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(1.37, result.AttributedCpuRatio); + Assert.NotNull(result.Note); + Assert.Contains("137%", result.Note, StringComparison.Ordinal); + Assert.Contains("impossible-claim", result.Note, StringComparison.Ordinal); + } + + /// Just above 1.0 is sampling noise between two independent series, not a lie — + /// the impossible flag waits for the slack threshold. + [Fact] + public void SlightlyOverOne_CarriesNoNote() + { + var result = CpuAttribution.Compute(7500, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(1.042, result.AttributedCpuRatio); + Assert.Null(result.Note); + } + + [Fact] + public void NoSamples_OmitsRatio_AndSaysWhy() + { + var result = CpuAttribution.Compute(5000, Start, End, 0, null, null, null, 8); + + Assert.Equal(5000, result.RankedCpuSeconds); + Assert.Null(result.SqlCpuSecondsInWindow); + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("no cpu_utilization samples", result.Note, StringComparison.Ordinal); + } + + [Fact] + public void NoCoreCount_OmitsRatio_AndSaysWhy() + { + var result = CpuAttribution.Compute(5000, Start, End, 60, Start, End, 25, cpuCount: 0); + + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("core count unavailable", result.Note, StringComparison.Ordinal); + } + + /// #2320's explicit degrade rule: a server whose CPU series starts mid-window (added, + /// or monitoring resumed) would deflate the denominator and inflate the ratio — omit instead. + [Fact] + public void PartialCoverage_OmitsRatio_WithThePercentage() + { + var result = CpuAttribution.Compute(5000, Start, End, 30, Start.AddMinutes(30), End, 25, 8); + + Assert.Null(result.AttributedCpuRatio); + Assert.NotNull(result.Note); + Assert.Contains("50%", result.Note, StringComparison.Ordinal); + Assert.Contains("partial denominator", result.Note, StringComparison.Ordinal); + } + + /// Samples straddling the window edges clamp to full coverage — a series wider than the + /// window is the NORMAL case (the store holds more history than any one read). + [Fact] + public void SamplesBeyondTheWindow_ClampToFullCoverage() + { + var result = CpuAttribution.Compute( + 5000, Start, End, 120, Start.AddHours(-1), End.AddHours(1), 25, 8); + + Assert.Equal(0.694, result.AttributedCpuRatio); + } + + /// An idle box measures zero CPU-seconds; a ratio against zero is undefined, and the + /// measured zero is still reported so the caller sees WHY. + [Fact] + public void ZeroMeasuredCpu_OmitsRatio_ReportsTheZero() + { + var result = CpuAttribution.Compute(5000, Start, End, 60, Start, End, avgSqlCpuPercent: 0, cpuCount: 8); + + Assert.Equal(0, result.SqlCpuSecondsInWindow); + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("zero", result.Note, StringComparison.Ordinal); + } + + [Fact] + public void EmptyWindow_OmitsRatio() + { + var result = CpuAttribution.Compute(5000, Start, Start, 60, Start, End, 25, 8); + + Assert.Null(result.AttributedCpuRatio); + Assert.Contains("window is empty", result.Note, StringComparison.Ordinal); + } + + /// The numerator is rounded for emission but the ratio divides the RAW value — rounding + /// before dividing would move the third decimal on big windows. + [Fact] + public void RankedSecondsRoundToOneDecimal_RatioToThree() + { + var result = CpuAttribution.Compute(1234.5678, Start, End, 60, Start, End, 25, 8); + + Assert.Equal(1234.6, result.RankedCpuSeconds); + Assert.Equal(0.171, result.AttributedCpuRatio); + } +} diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index 439bd078..edf417ac 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -68,8 +68,8 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo ### Query Performance Tools | Tool | Purpose | Key Parameters | |------|---------|----------------| - | `get_top_queries_by_cpu` | Expensive queries from plan cache with DOP, spills, query_hash. `max_dop` is a lifetime-max for the cached plan, not current parallelism - confirm with `analyze_query_plan` | `server_name`, `hours_back`, `top`, `database_name`, `parallel_only`, `min_dop` | - | `get_top_procedures_by_cpu` | Expensive stored procedures by CPU time | `server_name`, `hours_back`, `top`, `database_name` | + | `get_top_queries_by_cpu` | Expensive queries from plan cache with DOP, spills, query_hash. `max_dop` is a lifetime-max for the cached plan, not current parallelism - confirm with `analyze_query_plan`. `cpu_attribution.attributed_cpu_ratio` says how much of the box's measured CPU the returned rows explain | `server_name`, `hours_back`, `top`, `database_name`, `parallel_only`, `min_dop` | + | `get_top_procedures_by_cpu` | Expensive stored procedures by CPU time, with the same `cpu_attribution` disclosure | `server_name`, `hours_back`, `top`, `database_name` | | `get_query_store_top` | Expensive queries from Query Store (persistent) | `server_name`, `hours_back`, `top`, `database_name` | | `get_query_trend` | Time-series for a specific query by query_hash | `query_hash` (required), `database_name` (required), `server_name`, `hours_back` | | `get_query_duration_trend` | Average query duration over time (detect degradation) | `server_name`, `hours_back` | diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs index b244a97b..fb776b77 100644 --- a/Lite/Mcp/McpQueryTools.cs +++ b/Lite/Mcp/McpQueryTools.cs @@ -9,7 +9,7 @@ namespace PerformanceMonitorLite.Mcp; [McpServerToolType] public sealed class McpQueryTools { - [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means no stored text for the group). Supports database and parallelism filtering. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note.")] + [McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive queries from sys.dm_exec_query_stats (plan cache). Best for: currently cached queries with detailed per-execution stats, DOP, spills, and query_hash for trending. Returns query_hash, query_plan_hash, sql_handle, plan_handle, and host_object (the hosting procedure/function for proc-hosted statements, null for ad-hoc) — groups key on (database, query_hash, host_object), so INSERT...EXEC callers in different procedures report separately with their own text. distinct_texts counts statement texts merged into a group (>1 = ad-hoc literal variants or pre-upgrade history; query_text is one representative, 0 means no stored text for the group). Supports database and parallelism filtering. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")] public static async Task GetTopQueriesByCpu( LocalDataService dataService, ServerManager serverManager, @@ -31,15 +31,37 @@ public static async Task GetTopQueriesByCpu( var topError = McpHelpers.ValidateTop(top, "top"); if (topError != null) return topError; + /* Captured BEFORE the ranking read, whose window is its own internal UtcNow — hoisting + shrinks the numerator/denominator window skew from the ranking query's full duration to + call-entry overhead (review catch; threading one instant INTO the shared ranking read's + signature is the only way to zero it, and sub-microsecond against an hours window does + not buy that churn). */ + var nowUtc = DateTime.UtcNow; var rows = await dataService.GetTopQueriesByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); if (rows.Count == 0) { return McpHelpers.Status("unavailable", "No query stats available for the specified time range."); } - IEnumerable filtered = rows; - if (parallel_only || min_dop > 1) - filtered = filtered.Where(r => r.MaxDop > 1 && r.MaxDop >= (min_dop > 1 ? min_dop : 2)); + var filtered = rows + .Where(r => !(parallel_only || min_dop > 1) || (r.MaxDop > 1 && r.MaxDop >= (min_dop > 1 ? min_dop : 2))) + .ToList(); + + /* #2320: what fraction of the box's measured CPU the RETURNED rows explain — numerator is + the caller-visible ranking (post top-N, post filters), denominator is measured, and the + ratio is omitted rather than invented when a denominator piece is missing. One nowUtc + backs the aggregate read AND the ratio math, and the two independent reads run + concurrently (review catches; Darling has both by construction). */ + var cpuAggregateTask = dataService.GetCpuWindowAggregateAsync(resolved.ServerId, nowUtc.AddHours(-hours_back), nowUtc); + var propertiesTask = dataService.GetLatestServerPropertiesAsync(resolved.ServerId); + await Task.WhenAll(cpuAggregateTask, propertiesTask); + var cpuAggregate = await cpuAggregateTask; + var properties = await propertiesTask; + var attribution = CpuAttribution.Compute( + filtered.Sum(r => r.TotalCpuMs) / 1000.0, + nowUtc.AddHours(-hours_back), nowUtc, + cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, + properties?.CpuCount ?? 0); var result = filtered.Select(r => new { @@ -86,6 +108,13 @@ public static async Task GetTopQueriesByCpu( { server = resolved.ServerName, hours_back, + cpu_attribution = new + { + ranked_cpu_seconds = attribution.RankedCpuSeconds, + sql_cpu_seconds_in_window = attribution.SqlCpuSecondsInWindow, + attributed_cpu_ratio = attribution.AttributedCpuRatio, + note = attribution.Note + }, queries = result }, McpHelpers.JsonOptions); } @@ -95,7 +124,7 @@ public static async Task GetTopQueriesByCpu( } } - [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time. Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note.")] + [McpServerTool(Name = "get_top_procedures_by_cpu"), Description("Gets the most expensive stored procedures ranked by total CPU time. Shows execution counts, CPU/elapsed times, and I/O metrics. Delta-based: requires ~30 minutes after adding a new server before data appears. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes for the plan's time in cache (same semantics as max_dop), not windowed — totals and avgs are windowed deltas; rows where an extreme provably predates the window carry extremes_note. Also returns cpu_attribution: the returned rows' summed CPU-seconds against the SQL process's measured CPU-seconds for the window (avg cpu_utilization % x core count x window) - attributed_cpu_ratio says how much of the box the ranking explains; when the CPU series or core count is missing, or covers too little of the window, the ratio is omitted rather than invented.")] public static async Task GetTopProceduresByCpu( LocalDataService dataService, ServerManager serverManager, @@ -115,6 +144,8 @@ public static async Task GetTopProceduresByCpu( var topError = McpHelpers.ValidateTop(top, "top"); if (topError != null) return topError; + /* Same pre-read capture as the queries tool — the skew shrinks to call-entry overhead. */ + var nowUtc = DateTime.UtcNow; var rows = await dataService.GetTopProceduresByCpuAsync(resolved.ServerId, hours_back, top, databaseNames: string.IsNullOrEmpty(database_name) ? null : new[] { database_name }); if (rows.Count == 0) { @@ -123,6 +154,19 @@ public static async Task GetTopProceduresByCpu( "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); } + /* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, one + nowUtc backing aggregate and ratio, same concurrent independent reads. */ + var cpuAggregateTask = dataService.GetCpuWindowAggregateAsync(resolved.ServerId, nowUtc.AddHours(-hours_back), nowUtc); + var propertiesTask = dataService.GetLatestServerPropertiesAsync(resolved.ServerId); + await Task.WhenAll(cpuAggregateTask, propertiesTask); + var cpuAggregate = await cpuAggregateTask; + var properties = await propertiesTask; + var attribution = CpuAttribution.Compute( + rows.Sum(r => r.TotalCpuMs) / 1000.0, + nowUtc.AddHours(-hours_back), nowUtc, + cpuAggregate.SampleCount, cpuAggregate.FirstSample, cpuAggregate.LastSample, cpuAggregate.AvgSqlCpuPercent, + properties?.CpuCount ?? 0); + var result = rows.Select(r => new { database_name = r.DatabaseName, @@ -153,6 +197,13 @@ public static async Task GetTopProceduresByCpu( { server = resolved.ServerName, hours_back, + cpu_attribution = new + { + ranked_cpu_seconds = attribution.RankedCpuSeconds, + sql_cpu_seconds_in_window = attribution.SqlCpuSecondsInWindow, + attributed_cpu_ratio = attribution.AttributedCpuRatio, + note = attribution.Note + }, procedures = result }, McpHelpers.JsonOptions); } diff --git a/Lite/Services/LocalDataService.Cpu.cs b/Lite/Services/LocalDataService.Cpu.cs index 50e7be8a..1e4fff36 100644 --- a/Lite/Services/LocalDataService.Cpu.cs +++ b/Lite/Services/LocalDataService.Cpu.cs @@ -56,8 +56,51 @@ FROM v_cpu_utilization_stats return items; } + + /// + /// The attributed-CPU denominator's pieces (#2320): sample count, coverage bounds, and average SQL + /// CPU% over the window. Windowed on collection_time (UTC) — the SAME bounds the top-queries and + /// top-procedures rankings use — so numerator and denominator share collection gaps; sample_time's + /// server-local skew is irrelevant to an average. Takes the window EXPLICITLY (not hours_back) so + /// the caller can hand the identical bounds to CpuAttribution.Compute — review catch: three + /// independently-sampled UtcNow calls backing one disclosure is drift by construction. + /// + public async Task GetCpuWindowAggregateAsync(int serverId, DateTime startUtc, DateTime endUtc) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + command.CommandText = @" +SELECT + COUNT(*), + MIN(collection_time), + MAX(collection_time), + AVG(CAST(sqlserver_cpu_utilization AS DOUBLE)) +FROM v_cpu_utilization_stats +WHERE server_id = $1 +AND collection_time >= $2 +AND collection_time <= $3"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startUtc }); + command.Parameters.Add(new DuckDBParameter { Value = endUtc }); + + using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + return new CpuWindowAggregateRow(0, null, null, null); + } + + return new CpuWindowAggregateRow( + reader.IsDBNull(0) ? 0 : Convert.ToInt32(reader.GetValue(0)), + reader.IsDBNull(1) ? null : reader.GetDateTime(1), + reader.IsDBNull(2) ? null : reader.GetDateTime(2), + reader.IsDBNull(3) ? null : reader.GetDouble(3)); + } } +public sealed record CpuWindowAggregateRow(int SampleCount, DateTime? FirstSample, DateTime? LastSample, double? AvgSqlCpuPercent); + public class CpuUtilizationRow { public DateTime SampleTime { get; set; } diff --git a/PerformanceMonitor.Common/CpuAttribution.cs b/PerformanceMonitor.Common/CpuAttribution.cs new file mode 100644 index 00000000..64b74fe9 --- /dev/null +++ b/PerformanceMonitor.Common/CpuAttribution.cs @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; + +namespace PerformanceMonitor.Common +{ + /// + /// The attributed-CPU denominator for the top-queries/procedures MCP reads (#2320, split from #2235) — + /// how much of the instance's actually-consumed CPU the returned ranking explains. Pre-#2290 the reads + /// explained ~10% of the box and nothing said so; a caller chased the visible 10% assuming it was + /// everything. And the number catches impossible claims at a glance: an external comparison died the + /// moment someone divided its worker_time sum by the box's available CPU-seconds and got 137%. Both + /// SKUs' tools hand the caller numerator, denominator, and ratio instead of leaving the division to be + /// re-derived — ONE computation here, so the two cannot disagree. + /// + /// The denominator is measured, not theoretical: the SQL process's average CPU% over the window + /// (the cpu_utilization series both stores already collect) × core count (server_properties) × window + /// seconds. When a piece is missing — no CPU samples, no properties snapshot, or the series covers too + /// little of the window — the ratio is OMITTED, never invented (#2320's explicit degrade rule). + /// + public static class CpuAttribution + { + /// The CPU series must span at least this fraction of the requested window for the + /// denominator to be honest — below it a server added (or monitoring resumed) mid-window would + /// deflate measured CPU-seconds and inflate the ratio. + public const double MinimumCoverageFraction = 0.9; + + /// Below this ratio the result carries the "not the whole story" note — the #2235 history + /// says real post-fix rankings explain roughly a third, so half is a generous line between "normal + /// plan-cache attribution loss" and "worth saying out loud". + public const double LowRatioThreshold = 0.5; + + /// Above this ratio the returned rows claim more CPU than the process measurably consumed — + /// the impossible-claim marker (137% is how the Datadog comparison died). Slack above 1.0 covers + /// sampling noise between the two series. + public const double OverAttributionThreshold = 1.1; + + /// + /// A null always comes with a saying why. + /// is usually null alongside it (the denominator could not be + /// measured) — EXCEPT the measured-zero case, where the zero is reported and only the ratio is + /// omitted, so the caller sees WHY dividing was refused. When the ratio is present the note is + /// null unless the ratio is low or impossible. + /// + public sealed record Result( + double RankedCpuSeconds, + double? SqlCpuSecondsInWindow, + double? AttributedCpuRatio, + string? Note); + + /// + /// is the summed windowed CPU of the rows the tool RETURNS + /// (post top-N, post filters) — the ratio answers "what does the caller-visible ranking explain", + /// not "what does the whole table hold". The sample aggregate (count / first / last / + /// ) comes from the store's cpu_utilization series windowed on + /// the SAME collection_time bounds as the ranking, so numerator and denominator share gaps. + /// + public static Result Compute( + double rankedCpuSeconds, + DateTime windowStartUtc, + DateTime windowEndUtc, + int sampleCount, + DateTime? firstSampleUtc, + DateTime? lastSampleUtc, + double? avgSqlCpuPercent, + int cpuCount) + { + var ranked = Math.Round(rankedCpuSeconds, 1); + var windowSeconds = (windowEndUtc - windowStartUtc).TotalSeconds; + + if (windowSeconds <= 0) + { + return new Result(ranked, null, null, + "the requested window is empty; ratio omitted"); + } + + if (sampleCount == 0 || avgSqlCpuPercent is null || firstSampleUtc is null || lastSampleUtc is null) + { + return new Result(ranked, null, null, + "no cpu_utilization samples in the window, so measured CPU-seconds cannot be computed; ratio omitted rather than invented"); + } + + if (cpuCount <= 0) + { + return new Result(ranked, null, null, + "core count unavailable (no server_properties snapshot), so measured CPU-seconds cannot be computed; ratio omitted rather than invented"); + } + + var coverageStart = firstSampleUtc.Value > windowStartUtc ? firstSampleUtc.Value : windowStartUtc; + var coverageEnd = lastSampleUtc.Value < windowEndUtc ? lastSampleUtc.Value : windowEndUtc; + var coverageFraction = Math.Max(0, (coverageEnd - coverageStart).TotalSeconds) / windowSeconds; + if (coverageFraction < MinimumCoverageFraction) + { + return new Result(ranked, null, null, + $"cpu_utilization covers only {Math.Round(coverageFraction * 100).ToString(CultureInfo.InvariantCulture)}% of the window; ratio omitted rather than computed against a partial denominator"); + } + + var sqlCpuSeconds = avgSqlCpuPercent.Value / 100.0 * cpuCount * windowSeconds; + if (sqlCpuSeconds <= 0) + { + return new Result(ranked, Math.Round(sqlCpuSeconds, 1), null, + "the SQL process's measured CPU in the window is zero; ratio omitted"); + } + + var ratio = rankedCpuSeconds / sqlCpuSeconds; + var pct = Math.Round(ratio * 100).ToString(CultureInfo.InvariantCulture); + + string? note = null; + if (ratio > OverAttributionThreshold) + { + note = $"the returned rows' CPU is {pct}% of the SQL process's measured CPU-seconds — more than the process consumed. Treat this as an impossible-claim marker: suspect double-counted deltas, clock skew between the two series, or a CPU-series gap before trusting the ranking's absolute numbers."; + } + else if (ratio < LowRatioThreshold) + { + note = $"the returned rows explain {pct}% of the SQL process's measured CPU-seconds in this window. The remainder is plans evicted between snapshots, statements outside the top-N or filters, zero-cost rows, and non-query CPU — a low ratio means the visible ranking is not the whole story."; + } + + return new Result(ranked, Math.Round(sqlCpuSeconds, 1), Math.Round(ratio, 3), note); + } + } +}