diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f4a25b34..b32742c6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **The top-CPU rankings hand the caller their own denominator: cpu_window** ([#2320]) - get_top_queries_by_cpu and get_top_procedures_by_cpu (both apps) now report what fraction of the SQL CPU the box ACTUALLY burned the returned rows explain: measured_sql_cpu_seconds (the collected utilization series' average x the server's core count x the window, Azure-aware via COALESCE(vcore_count, cpu_count)), attributed_cpu_seconds (the returned rows' windowed total), and attributed_ratio. Twice earned on #2235: pre-#2290 the reads explained ~10% of one production box and nothing said so, and the Datadog disagreement died the moment its worker_time sum was divided by the box's consumed CPU-seconds and produced a physically impossible 137% - this field is that division as a first-class output. A ratio under half carries a note saying where the remainder lives (below the ranking cut, plans evicted between snapshots, uncached work); a ratio past 1.1 carries the sampling-skew note - the impossibility detector. The whole object is NULL whenever the denominator cannot be trusted (fewer than three samples, a sampled span under half the window - span rather than any count-per-minute expectation, so a deliberately slowed collection cadence still qualifies - unknown core count, hard-zero average): omitted, never fabricated. Shared CpuAttribution math, decision table pinned identically in both suites.
- **The generic webhook can now hand automation the alert's structure: `{{context_json}}`, `{{incidents_json}}` and `{{dedup_key}}`** ([#2302]) - everything automation needs already existed structured inside the product, and every channel then flattened a different half: Teams/Slack keep incident structure but bury the scalars in display strings, the generic channel keeps discrete scalars but joins the whole context into one " | " line whose delimiters collide with Victim SQL, and PagerDuty keeps only the first incident's key. The reporting consumer measured the cost precisely: 31 of 49 Logic App actions existed only to undo the flattening, including two silent-failure guesses (deriving the server by splitting the summary on " on ", detecting incident sections by substring). The new tokens are raw JSON VALUES substituted unquoted - they deliberately bypass the per-token JSON escaping, via an explicit raw set that leaves the single-pass MatchEvaluator untouched - and their shape is EXACTLY the AlertContextSerializer projection persisted as alert-history ContextJson, so a consumer parses one shape whether it reads the webhook or the history row (pinned by a round-trip test through the same serializer). `{{dedup_key}}` carries the very key the PagerDuty channel derives - including the stable serverId+metric fallback that existed in code but was never exposed to any consumer, which had forced title-matching heuristics for level/threshold alerts - so tickets correlate across channels. The shipped default template is byte-identical (pinned), unknown tokens stay literal, and a template that quotes a raw token is caught by the existing well-formedness check as a config error. Both SKUs, since the whole channel lives in the shared Notifications project.
- **get_collection_health now carries a sweep_pressure verdict, so half-rate collection stops hiding behind 40 healthy collectors** ([#2296]) - two cross-region servers were collecting at half their configured cadence: their four heaviest collectors averaged ~60.7s of combined execution against a 60s sweep, so the serial collection body could never finish inside its interval, every relaunch was skipped (~50 service-log warnings/hour), and NOTHING else surfaced it - every collector reported HEALTHY, because from each one's own seat nothing was wrong. The tool now rolls the collectors' combined demand (average duration amortized by each collector's own cadence) against the minute the fastest cadence holds and serves busy_ms_per_minute / busy_percent / a verdict (OK, AT_RISK at 75%, SATURATED at 100%) plus the three heaviest contributors - attribution, because "which collectors spend the budget" is the actionable half of the answer. Deliberately built from the collectors' own execution times rather than delivered-gap statistics: at fleet scale the delivered cadence stretches benignly from bounded sweep concurrency (queueing), so gap-based detection would flag every server and drown the two that matter; execution demand is the arithmetic behind the watchdog's own "has not completed after Ns of EXECUTION" line and queueing cannot inflate it. The decision lives in the shared SweepPressureClassifier (PerformanceMonitor.Common) with the same decision table pinned in both suites, and both SKUs' tools serve the identical shape. Root-cause options for the two saturated servers (move them in-region, or lengthen their cadence) stay tracked on the issue - this change makes the condition visible either way.
@@ -2791,6 +2792,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#2246]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2246
[#2300]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2300
[#2312]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2312
+[#2320]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2320
[#2306]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2306
[#2302]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2302
[#2296]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2296
diff --git a/Darling/Darling.Tests/CpuAttributionTests.cs b/Darling/Darling.Tests/CpuAttributionTests.cs
new file mode 100644
index 000000000..8d957fd1f
--- /dev/null
+++ b/Darling/Darling.Tests/CpuAttributionTests.cs
@@ -0,0 +1,109 @@
+/*
+ * 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 PerformanceMonitor.Common;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Decision-table pins for the shared (#2320) — the denominator the
+/// top-CPU rankings never handed the caller. This SAME table is pinned identically in Lite.Tests so
+/// the two SKUs cannot drift. The load-bearing cases are #2235's field numbers: the pre-fix reads
+/// explained ~10% of one box and nothing said so, and the Datadog disagreement died the moment its
+/// worker_time sum was divided by the box's consumed CPU-seconds and produced 137%.
+///
+public sealed class CpuAttributionTests
+{
+ /* The #2235 window: 8 vCPU, 2 hours, RDS CPU averaging 18% → 10,368 measured core-seconds. */
+ private const double FieldAvgPct = 18.0;
+ private const int FieldCores = 8;
+ private const double FieldHours = 2.0;
+ private const int CoveredSamples = 120;
+ private const double FullSpan = 2.0;
+
+ [Fact]
+ public void TheFieldWindowComputesTheMeasuredDenominator()
+ {
+ var window = CpuAttribution.Compute(
+ attributedCpuMs: 3_000_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window);
+ Assert.Equal(10_368, window.Value.MeasuredSqlCpuSeconds, precision: 0);
+ Assert.Equal(3_000, window.Value.AttributedCpuSeconds, precision: 0);
+ Assert.Equal(0.289, window.Value.AttributedRatio, precision: 3);
+ }
+
+ /// Under half explained → the note says where the rest lives.
+ [Fact]
+ public void ALowRatioCarriesTheRemainderNote()
+ {
+ var window = CpuAttribution.Compute(3_000_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window!.Value.Note);
+ Assert.Contains("below the ranking cut", window.Value.Note, System.StringComparison.Ordinal);
+ }
+
+ ///
+ /// The impossibility detector: attributed exceeding measured by more than sampling skew could
+ /// explain gets the skew note — the exact division that settled #2235's Datadog claim (137%).
+ ///
+ [Fact]
+ public void AttributedBeyondMeasuredCarriesTheSkewNote()
+ {
+ var window = CpuAttribution.Compute(14_229_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window);
+ Assert.True(window.Value.AttributedRatio > 1.3);
+ Assert.Contains("exceeds the measured total", window.Value.Note, System.StringComparison.Ordinal);
+ }
+
+ /// An ordinary healthy ratio says nothing — notes are for the two failure directions.
+ [Fact]
+ public void AMidRatioCarriesNoNote()
+ {
+ var window = CpuAttribution.Compute(8_000_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window);
+ Assert.Null(window.Value.Note);
+ }
+
+ ///
+ /// Every way the denominator can be untrustworthy omits the window rather than fabricating one:
+ /// no average, no samples, unknown or nonsensical cores, a degenerate window, thin coverage,
+ /// and a hard-zero average (the ratio would divide by zero).
+ ///
+ [Fact]
+ public void AnUnsupportableDenominatorIsOmittedNeverFabricated()
+ {
+ Assert.Null(CpuAttribution.Compute(1_000, null, CoveredSamples, FullSpan, FieldCores, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, 0, FullSpan, FieldCores, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, CoveredSamples, FullSpan, null, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, CoveredSamples, FullSpan, 0, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, 0));
+ /* A 24h window whose samples span only 5 hours: the average extrapolates a fragment —
+ span-based on purpose, so a SLOW but steady cadence still qualifies (the review catch). */
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, 300, 5.0, FieldCores, 24));
+ /* Two lonely points can bracket a wide span — the sample floor rejects them. */
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, 2, FullSpan, FieldCores, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, 0.0, CoveredSamples, FullSpan, FieldCores, FieldHours));
+ }
+
+ ///
+ /// The span floor's boundary: samples spanning exactly half the window qualify — and cadence
+ /// never enters it, so a server whose cpu_utilization schedule was slowed to 5 minutes (24
+ /// samples in 2 hours) qualifies exactly like a 1-minute one (the review catch: a
+ /// count-per-minute expectation would have disqualified it permanently).
+ ///
+ [Fact]
+ public void SpanAtTheFloorQualifies_AtAnyCadence()
+ {
+ Assert.NotNull(CpuAttribution.Compute(1_000, FieldAvgPct, 60, FieldHours * 0.5, FieldCores, FieldHours));
+ Assert.NotNull(CpuAttribution.Compute(1_000, FieldAvgPct, 24, FullSpan, FieldCores, FieldHours));
+ }
+}
diff --git a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
index 0be0d66ec..464436852 100644
--- a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
@@ -578,6 +578,11 @@ public async Task DataTools_ReadPlantedRows_AgainstDevPostgres()
var newer = older.AddMinutes(1);
await PlantCpuAsync(connection, newer, ct);
+ /* #2320: two more CPU samples, hours apart, so the attribution gate's floors are met for
+ the default 24h window (>= 3 samples spanning >= half of it) and cpu_window computes
+ through the REAL tool call — the wiring the pure-math pins cannot reach. */
+ await PlantCpuAsync(connection, newer.AddHours(-23), ct);
+ await PlantCpuAsync(connection, newer.AddHours(-13), ct);
await PlantWaitStatsAsync(connection, older, newer, ct);
await PlantMemoryStatsAsync(connection, newer, ct);
await PlantMemoryClerksAsync(connection, newer, ct);
@@ -605,6 +610,19 @@ public async Task DataTools_ReadPlantedRows_AgainstDevPostgres()
var q = await DarlingMcpDataTools.GetTopQueriesByCpu(postgres, ServerName);
AssertServerEnvelope(q, "queries");
Assert.Contains("0xE2EDATAHASH", q, StringComparison.Ordinal); /* the planted query surfaced */
+
+ /* #2320: cpu_window computed END TO END — reader SQL against the real schema (a column
+ typo would hide behind the degrade-to-null catch forever, which is why this exists),
+ the planted 40% average x cpu_count 16 x the 24h window, and the tiny planted
+ numerator drawing the below-half note. */
+ using (var qDoc = JsonDocument.Parse(q))
+ {
+ var cpuWindow = qDoc.RootElement.GetProperty("cpu_window");
+ Assert.NotEqual(JsonValueKind.Null, cpuWindow.ValueKind);
+ Assert.Equal(0.40 * 16 * 24 * 3600, cpuWindow.GetProperty("measured_sql_cpu_seconds").GetDouble(), precision: 0);
+ Assert.True(cpuWindow.GetProperty("attributed_ratio").GetDouble() < 0.5);
+ Assert.Contains("below the ranking cut", cpuWindow.GetProperty("note").GetString(), StringComparison.Ordinal);
+ }
AssertServerEnvelope(await DarlingMcpDataTools.GetTopProceduresByCpu(postgres, ServerName), "procedures");
AssertServerEnvelope(await DarlingMcpDataTools.GetQueryStoreTop(postgres, ServerName), "queries");
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
index c1b5edd66..24b5d626f 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs
@@ -421,6 +421,12 @@ public DarlingWorker(ILogger logger, ILoggerFactory loggerFactory
_mcpState = mcpState;
_webState = webState;
_registryState = registryState;
+
+ /* #2320 (round-4 review catch): set HERE, not only in the MCP host — mcp.enabled is OFF by
+ default, so on a default install the web dashboard's /api/read mirror is the only caller
+ of the top-CPU tools, and a logger set only by the MCP host would leave that path's
+ degrade-to-null silent. The worker always runs. */
+ Mcp.DarlingMcpDataTools.DegradeLogger = logger;
}
private sealed class ServerLoopState
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
index 79992acff..05fcb685c 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
@@ -155,6 +155,67 @@ FROM cpu_utilization_stats
ORDER BY sample_time
""";
+ ///
+ /// #2320: the attribution denominator's raw ingredients for one window — the average SQL-process
+ /// CPU percent and how many samples that average rests on (the coverage gate's input; the shared
+ /// CpuAttribution.Compute owns the trust decision). Windows on collection_time like every other
+ /// windowed read here; the de-skewed sample_time is irrelevant to an average.
+ ///
+ public static async Task<(double? AvgSqlCpuPercent, int Samples, double SpanHours)> GetCpuWindowAverageAsync(
+ NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, CancellationToken cancellationToken = default)
+ {
+ /* The span (last minus first sample) rides along because the coverage gate judges SPAN, not
+ count-per-minute — the collector's cadence is user-configurable, so a count expectation
+ would permanently disqualify a legitimately slowed server (the review catch). NULL-valued
+ rows are excluded up front (the round-9 catch): AVG would skip them anyway, but COUNT(*)
+ and the span must rest on the same rows the average does, or Samples overstates the
+ evidence behind it. */
+ const string sql = """
+ SELECT
+ AVG(sqlserver_cpu_utilization)::float8,
+ COUNT(*)::int,
+ COALESCE(EXTRACT(EPOCH FROM (MAX(collection_time) - MIN(collection_time))) / 3600.0, 0)::float8
+ FROM cpu_utilization_stats
+ WHERE server_id = $1
+ AND collection_time >= $2
+ AND collection_time <= $3
+ AND sqlserver_cpu_utilization IS NOT NULL
+ """;
+ await using var command = postgres.CreateCommand(sql);
+ AddInt(command, serverId);
+ AddTimestamp(command, startUtc);
+ AddTimestamp(command, endUtc);
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ {
+ return (null, 0, 0);
+ }
+
+ return (reader.IsDBNull(0) ? null : reader.GetDouble(0), reader.GetInt32(1), reader.IsDBNull(2) ? 0 : reader.GetDouble(2));
+ }
+
+ ///
+ /// #2320: the server's core count from its latest properties snapshot — the other half of the
+ /// denominator. Null when properties were never collected; the caller omits the ratio then.
+ ///
+ public static async Task GetLatestCpuCountAsync(
+ NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default)
+ {
+ /* COALESCE(vcore_count, cpu_count) — the same Azure-aware read the FinOps utilization CTE
+ uses in both SKUs: on Azure SQL DB the vcore count is the honest denominator. */
+ const string sql = """
+ SELECT COALESCE(vcore_count, cpu_count)
+ FROM server_properties
+ WHERE server_id = $1
+ ORDER BY collection_time DESC
+ LIMIT 1
+ """;
+ await using var command = postgres.CreateCommand(sql);
+ AddInt(command, serverId);
+ var result = await command.ExecuteScalarAsync(cancellationToken);
+ return result is int cores && cores > 0 ? cores : null;
+ }
+
public static async Task> GetCpuUtilizationAsync(
NpgsqlDataSource postgres, int serverId, DateTime startUtc, CancellationToken cancellationToken = default)
{
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
index ea0dfffe8..423836b26 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
@@ -430,7 +430,50 @@ 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.")]
+ ///
+ /// #2320 (round-2 review catch): the SERVICE's logger, set once by the MCP host at startup so
+ /// the deliberate degrade-to-null path below still leaves a trace. A static ambient rather than
+ /// an injected method parameter, deliberately: the SDK decides DI-vs-tool-parameter by what is
+ /// registered at schema-build time, so a logger parameter would put the ADVERTISED SCHEMA at
+ /// the mercy of registration order (the parity pin caught exactly that). Null (tests, or a host
+ /// that never set it) degrades to today's silent null — same contract as Lite's static
+ /// AppLogger, which is this pattern's precedent.
+ ///
+ internal static Microsoft.Extensions.Logging.ILogger? DegradeLogger { get; set; }
+
+ ///
+ /// #2320 (review catches, both): the attribution reads must never fail the tool call — the rows
+ /// are already fetched, and a data problem in the DENOMINATOR is exactly the omitted-never-
+ /// fabricated case, so any exception here collapses to "no cpu_window". And the two reads are
+ /// independent of each other, so they run concurrently — one round-trip of latency, not two.
+ ///
+ private static async Task TryComputeCpuWindowAsync(
+ NpgsqlDataSource postgres, int serverId, DateTime windowStart, DateTime windowEnd, double attributedCpuMs, int hoursBack)
+ {
+ try
+ {
+ var averageTask = DarlingDataReader.GetCpuWindowAverageAsync(postgres, serverId, windowStart, windowEnd);
+ var coresTask = DarlingDataReader.GetLatestCpuCountAsync(postgres, serverId);
+ await Task.WhenAll(averageTask, coresTask);
+ var (avgSqlCpu, samples, spanHours) = averageTask.Result;
+ return CpuAttribution.Compute(attributedCpuMs, avgSqlCpu, samples, spanHours, coresTask.Result, hoursBack);
+ }
+ catch (Exception ex)
+ {
+ /* Logged, not silent (the round-2 review catch): a null from a THROW is otherwise
+ indistinguishable from the legitimate thin-coverage null, and a systemic defect in
+ these reads would hide forever. A repeating warning here means a real bug. */
+ if (DegradeLogger is { } log)
+ {
+ Microsoft.Extensions.Logging.LoggerExtensions.LogWarning(log, ex,
+ "cpu_window computation failed for server_id {ServerId} — omitting the field; if this repeats, the attribution reads have a defect.",
+ serverId);
+ }
+ return null;
+ }
+ }
+
+ [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. cpu_window reports what fraction of the box's MEASURED SQL CPU the returned rows explain (attributed_ratio, from the collected utilization series x core count); null when that series cannot support the denominator - omitted, never fabricated.")]
public static async Task GetTopQueriesByCpu(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -462,16 +505,24 @@ the exact wrong conclusion this option exists to prevent. */
try
{
var now = DateTime.UtcNow;
+ var windowStart = now.AddHours(-hours_back);
var rows = await DarlingDataReader.GetTopQueriesByCpuAsync(
- postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name, rollUpByHostObject: rollUp);
+ postgres, resolved.ServerId, windowStart, now, top, database_name, rollUpByHostObject: rollUp);
if (rows.Count == 0)
return McpHelpers.Status("unavailable", "No query stats available for the specified time range.");
- IEnumerable filtered = rows;
+ var filtered = rows.AsEnumerable();
if (parallel_only || min_dop > 1)
filtered = filtered.Where(r => r.MaxDop > 1 && r.MaxDop >= (min_dop > 1 ? min_dop : 2));
+ var kept = filtered.ToList();
- var result = filtered.Select(r => new
+ /* #2320: the denominator the ranking never handed the caller — what fraction of the SQL
+ CPU the box ACTUALLY burned do the returned rows explain. Omitted (null) rather than
+ fabricated when the utilization series or core count can't support it. */
+ var attribution = await TryComputeCpuWindowAsync(
+ postgres, resolved.ServerId, windowStart, now, kept.Sum(r => r.TotalCpuUs / 1000.0), hours_back);
+
+ var result = kept.Select(r => new
{
database_name = r.DatabaseName,
query_hash = r.QueryHash,
@@ -529,6 +580,17 @@ 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",
+ /* #2320: null means the utilization series or core count could not support the
+ denominator — omitted, never fabricated. */
+ cpu_window = attribution is { } a
+ ? new
+ {
+ measured_sql_cpu_seconds = Math.Round(a.MeasuredSqlCpuSeconds),
+ attributed_cpu_seconds = Math.Round(a.AttributedCpuSeconds),
+ attributed_ratio = Math.Round(a.AttributedRatio, 3),
+ note = a.Note,
+ }
+ : null,
queries = result
}, McpHelpers.JsonOptions);
}
@@ -538,7 +600,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. cpu_window reports what fraction of the box's MEASURED SQL CPU the returned rows explain (attributed_ratio, from the collected utilization series x core count); null when that series cannot support the denominator - omitted, never fabricated.")]
public static async Task GetTopProceduresByCpu(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -557,12 +619,17 @@ public static async Task GetTopProceduresByCpu(
try
{
var now = DateTime.UtcNow;
- var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.ServerId, now.AddHours(-hours_back), now, top, database_name);
+ var windowStart = now.AddHours(-hours_back);
+ var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.ServerId, windowStart, now, top, database_name);
if (rows.Count == 0)
return McpHelpers.Status(
"unavailable",
"No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values.");
+ /* #2320: same attribution denominator as the queries tool. */
+ var attribution = await TryComputeCpuWindowAsync(
+ postgres, resolved.ServerId, windowStart, now, rows.Sum(r => r.TotalCpuUs / 1000.0), hours_back);
+
var result = rows.Select(r => new
{
database_name = r.DatabaseName,
@@ -593,6 +660,17 @@ public static async Task GetTopProceduresByCpu(
{
server = resolved.ServerName,
hours_back,
+ /* #2320: null means the utilization series or core count could not support the
+ denominator — omitted, never fabricated. */
+ cpu_window = attribution is { } a
+ ? new
+ {
+ measured_sql_cpu_seconds = Math.Round(a.MeasuredSqlCpuSeconds),
+ attributed_cpu_seconds = Math.Round(a.AttributedCpuSeconds),
+ attributed_ratio = Math.Round(a.AttributedRatio, 3),
+ note = a.Note,
+ }
+ : null,
procedures = result
}, McpHelpers.JsonOptions);
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
index 5a49a5e9e..4d3574c87 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs
@@ -445,6 +445,13 @@ listen value is itself loopback or a wildcard (0.0.0.0/::), which would collide
builder.Services.AddSingleton(postgres);
builder.Services.AddSingleton(new DarlingAnalysisService(postgres, planFetcher, _logger));
+ /* #2320 (review catch): the SERVICE's logger instance, so a tool's deliberate
+ degrade-to-null path can still leave a trace — the host's own logging factory has its
+ providers cleared above, which would make anything it minted a black hole. A static
+ ambient rather than a DI registration, so the ADVERTISED tool schema can never depend
+ on what happens to be registered at schema-build time (see DegradeLogger's doc). */
+ DarlingMcpDataTools.DegradeLogger = _logger;
+
/* Register MCP server with the analysis tool class. */
builder.Services
.AddMcpServer(options =>
diff --git a/Lite.Tests/CpuAttributionTests.cs b/Lite.Tests/CpuAttributionTests.cs
new file mode 100644
index 000000000..b5db3f91c
--- /dev/null
+++ b/Lite.Tests/CpuAttributionTests.cs
@@ -0,0 +1,109 @@
+/*
+ * 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 PerformanceMonitor.Common;
+using Xunit;
+
+namespace Lite.Tests;
+
+///
+/// Decision-table pins for the shared (#2320) — the denominator the
+/// top-CPU rankings never handed the caller. This SAME table is pinned identically in Darling.Tests so
+/// the two SKUs cannot drift. The load-bearing cases are #2235's field numbers: the pre-fix reads
+/// explained ~10% of one box and nothing said so, and the Datadog disagreement died the moment its
+/// worker_time sum was divided by the box's consumed CPU-seconds and produced 137%.
+///
+public sealed class CpuAttributionTests
+{
+ /* The #2235 window: 8 vCPU, 2 hours, RDS CPU averaging 18% → 10,368 measured core-seconds. */
+ private const double FieldAvgPct = 18.0;
+ private const int FieldCores = 8;
+ private const double FieldHours = 2.0;
+ private const int CoveredSamples = 120;
+ private const double FullSpan = 2.0;
+
+ [Fact]
+ public void TheFieldWindowComputesTheMeasuredDenominator()
+ {
+ var window = CpuAttribution.Compute(
+ attributedCpuMs: 3_000_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window);
+ Assert.Equal(10_368, window.Value.MeasuredSqlCpuSeconds, precision: 0);
+ Assert.Equal(3_000, window.Value.AttributedCpuSeconds, precision: 0);
+ Assert.Equal(0.289, window.Value.AttributedRatio, precision: 3);
+ }
+
+ /// Under half explained → the note says where the rest lives.
+ [Fact]
+ public void ALowRatioCarriesTheRemainderNote()
+ {
+ var window = CpuAttribution.Compute(3_000_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window!.Value.Note);
+ Assert.Contains("below the ranking cut", window.Value.Note, System.StringComparison.Ordinal);
+ }
+
+ ///
+ /// The impossibility detector: attributed exceeding measured by more than sampling skew could
+ /// explain gets the skew note — the exact division that settled #2235's Datadog claim (137%).
+ ///
+ [Fact]
+ public void AttributedBeyondMeasuredCarriesTheSkewNote()
+ {
+ var window = CpuAttribution.Compute(14_229_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window);
+ Assert.True(window.Value.AttributedRatio > 1.3);
+ Assert.Contains("exceeds the measured total", window.Value.Note, System.StringComparison.Ordinal);
+ }
+
+ /// An ordinary healthy ratio says nothing — notes are for the two failure directions.
+ [Fact]
+ public void AMidRatioCarriesNoNote()
+ {
+ var window = CpuAttribution.Compute(8_000_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours);
+
+ Assert.NotNull(window);
+ Assert.Null(window.Value.Note);
+ }
+
+ ///
+ /// Every way the denominator can be untrustworthy omits the window rather than fabricating one:
+ /// no average, no samples, unknown or nonsensical cores, a degenerate window, thin coverage,
+ /// and a hard-zero average (the ratio would divide by zero).
+ ///
+ [Fact]
+ public void AnUnsupportableDenominatorIsOmittedNeverFabricated()
+ {
+ Assert.Null(CpuAttribution.Compute(1_000, null, CoveredSamples, FullSpan, FieldCores, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, 0, FullSpan, FieldCores, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, CoveredSamples, FullSpan, null, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, CoveredSamples, FullSpan, 0, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, 0));
+ /* A 24h window whose samples span only 5 hours: the average extrapolates a fragment —
+ span-based on purpose, so a SLOW but steady cadence still qualifies (the review catch). */
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, 300, 5.0, FieldCores, 24));
+ /* Two lonely points can bracket a wide span — the sample floor rejects them. */
+ Assert.Null(CpuAttribution.Compute(1_000, FieldAvgPct, 2, FullSpan, FieldCores, FieldHours));
+ Assert.Null(CpuAttribution.Compute(1_000, 0.0, CoveredSamples, FullSpan, FieldCores, FieldHours));
+ }
+
+ ///
+ /// The span floor's boundary: samples spanning exactly half the window qualify — and cadence
+ /// never enters it, so a server whose cpu_utilization schedule was slowed to 5 minutes (24
+ /// samples in 2 hours) qualifies exactly like a 1-minute one (the review catch: a
+ /// count-per-minute expectation would have disqualified it permanently).
+ ///
+ [Fact]
+ public void SpanAtTheFloorQualifies_AtAnyCadence()
+ {
+ Assert.NotNull(CpuAttribution.Compute(1_000, FieldAvgPct, 60, FieldHours * 0.5, FieldCores, FieldHours));
+ Assert.NotNull(CpuAttribution.Compute(1_000, FieldAvgPct, 24, FullSpan, FieldCores, FieldHours));
+ }
+}
diff --git a/Lite/Mcp/McpQueryTools.cs b/Lite/Mcp/McpQueryTools.cs
index b244a97b5..5b6fc3680 100644
--- a/Lite/Mcp/McpQueryTools.cs
+++ b/Lite/Mcp/McpQueryTools.cs
@@ -9,7 +9,33 @@ 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.")]
+ ///
+ /// #2320 (review catches, both — mirrors Darling): the attribution reads must never fail the
+ /// tool call; any exception here collapses to "no cpu_window" per the omitted-never-fabricated
+ /// contract. The two reads are independent (each opens its own DuckDB connection) and run
+ /// concurrently.
+ ///
+ private static async Task TryComputeCpuWindowAsync(
+ LocalDataService dataService, int serverId, double attributedCpuMs, int hoursBack)
+ {
+ try
+ {
+ var averageTask = dataService.GetCpuWindowAverageAsync(serverId, hoursBack);
+ var coresTask = dataService.GetLatestCpuCountAsync(serverId);
+ await Task.WhenAll(averageTask, coresTask);
+ var (avgSqlCpu, samples, spanHours) = averageTask.Result;
+ return CpuAttribution.Compute(attributedCpuMs, avgSqlCpu, samples, spanHours, coresTask.Result, hoursBack);
+ }
+ catch (Exception ex)
+ {
+ /* Logged, not silent (the round-2 review catch): a null from a THROW is otherwise
+ indistinguishable from the legitimate thin-coverage null. Mirrors Darling. */
+ AppLogger.Warn("McpQueryTools", "cpu_window computation failed — omitting the field; if this repeats, the attribution reads have a defect.", ex);
+ return null;
+ }
+ }
+
+ [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. cpu_window reports what fraction of the box's MEASURED SQL CPU the returned rows explain (attributed_ratio, from the collected utilization series x core count); null when that series cannot support the denominator - omitted, never fabricated.")]
public static async Task GetTopQueriesByCpu(
LocalDataService dataService,
ServerManager serverManager,
@@ -40,8 +66,14 @@ public static async Task GetTopQueriesByCpu(
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 kept = filtered.ToList();
- var result = filtered.Select(r => new
+ /* #2320: the denominator the ranking never handed the caller — mirrors Darling. Null
+ (omitted) whenever the utilization series or core count can't support it. */
+ var attribution = await TryComputeCpuWindowAsync(
+ dataService, resolved.ServerId, kept.Sum(r => r.TotalCpuMs), hours_back);
+
+ var result = kept.Select(r => new
{
database_name = r.DatabaseName,
query_hash = r.QueryHash,
@@ -86,6 +118,17 @@ public static async Task GetTopQueriesByCpu(
{
server = resolved.ServerName,
hours_back,
+ /* #2320: null means the utilization series or core count could not support the
+ denominator — omitted, never fabricated. Mirrors Darling. */
+ cpu_window = attribution is { } a
+ ? new
+ {
+ measured_sql_cpu_seconds = Math.Round(a.MeasuredSqlCpuSeconds),
+ attributed_cpu_seconds = Math.Round(a.AttributedCpuSeconds),
+ attributed_ratio = Math.Round(a.AttributedRatio, 3),
+ note = a.Note,
+ }
+ : null,
queries = result
}, McpHelpers.JsonOptions);
}
@@ -95,7 +138,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. cpu_window reports what fraction of the box's MEASURED SQL CPU the returned rows explain (attributed_ratio, from the collected utilization series x core count); null when that series cannot support the denominator - omitted, never fabricated.")]
public static async Task GetTopProceduresByCpu(
LocalDataService dataService,
ServerManager serverManager,
@@ -123,6 +166,10 @@ 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 attribution denominator as the queries tool. Mirrors Darling. */
+ var attribution = await TryComputeCpuWindowAsync(
+ dataService, resolved.ServerId, rows.Sum(r => r.TotalCpuMs), hours_back);
+
var result = rows.Select(r => new
{
database_name = r.DatabaseName,
@@ -153,6 +200,17 @@ public static async Task GetTopProceduresByCpu(
{
server = resolved.ServerName,
hours_back,
+ /* #2320: null means the utilization series or core count could not support the
+ denominator — omitted, never fabricated. Mirrors Darling. */
+ cpu_window = attribution is { } a
+ ? new
+ {
+ measured_sql_cpu_seconds = Math.Round(a.MeasuredSqlCpuSeconds),
+ attributed_cpu_seconds = Math.Round(a.AttributedCpuSeconds),
+ attributed_ratio = Math.Round(a.AttributedRatio, 3),
+ note = a.Note,
+ }
+ : null,
procedures = result
}, McpHelpers.JsonOptions);
}
diff --git a/Lite/Services/AppLogger.cs b/Lite/Services/AppLogger.cs
index 2a59f562c..6bf44ef83 100644
--- a/Lite/Services/AppLogger.cs
+++ b/Lite/Services/AppLogger.cs
@@ -92,35 +92,20 @@ public static void Warn(string source, string message)
Log("WARN", source, message);
}
+ ///
+ /// Warning WITH the exception's full detail (#2320 review catch) — a warning-severity event whose
+ /// diagnosis still needs the stack and inner exceptions. Same emission as Error's, at WARN.
+ ///
+ public static void Warn(string source, string message, Exception ex)
+ {
+ LogWithException("WARN", source, message, ex);
+ }
+
public static void Error(string source, string message, Exception? ex = null)
{
if (ex != null)
{
- Log("ERROR", source, $"{message} | {ex.GetType().Name}: {ex.Message}");
- Log("ERROR", source, $"Stack: {ex.StackTrace}");
-
- /* Log all inner exceptions recursively */
- var inner = ex.InnerException;
- var depth = 1;
- while (inner != null)
- {
- Log("ERROR", source, $"Inner[{depth}]: {inner.GetType().Name}: {inner.Message}");
- Log("ERROR", source, $"Inner[{depth}] Stack: {inner.StackTrace}");
- inner = inner.InnerException;
- depth++;
- }
-
- /* For AggregateException, log all inner exceptions */
- if (ex is AggregateException aggEx)
- {
- var idx = 0;
- foreach (var innerEx in aggEx.InnerExceptions)
- {
- Log("ERROR", source, $"Aggregate[{idx}]: {innerEx.GetType().Name}: {innerEx.Message}");
- Log("ERROR", source, $"Aggregate[{idx}] Stack: {innerEx.StackTrace}");
- idx++;
- }
- }
+ LogWithException("ERROR", source, message, ex);
}
else
{
@@ -128,6 +113,35 @@ public static void Error(string source, string message, Exception? ex = null)
}
}
+ private static void LogWithException(string level, string source, string message, Exception ex)
+ {
+ Log(level, source, $"{message} | {ex.GetType().Name}: {ex.Message}");
+ Log(level, source, $"Stack: {ex.StackTrace}");
+
+ /* Log all inner exceptions recursively */
+ var inner = ex.InnerException;
+ var depth = 1;
+ while (inner != null)
+ {
+ Log(level, source, $"Inner[{depth}]: {inner.GetType().Name}: {inner.Message}");
+ Log(level, source, $"Inner[{depth}] Stack: {inner.StackTrace}");
+ inner = inner.InnerException;
+ depth++;
+ }
+
+ /* For AggregateException, log all inner exceptions */
+ if (ex is AggregateException aggEx)
+ {
+ var idx = 0;
+ foreach (var innerEx in aggEx.InnerExceptions)
+ {
+ Log(level, source, $"Aggregate[{idx}]: {innerEx.GetType().Name}: {innerEx.Message}");
+ Log(level, source, $"Aggregate[{idx}] Stack: {innerEx.StackTrace}");
+ idx++;
+ }
+ }
+ }
+
public static void Debug(string source, string message)
{
#if DEBUG
diff --git a/Lite/Services/LocalDataService.Cpu.cs b/Lite/Services/LocalDataService.Cpu.cs
index 50e7be8ae..cb1c1a1d7 100644
--- a/Lite/Services/LocalDataService.Cpu.cs
+++ b/Lite/Services/LocalDataService.Cpu.cs
@@ -56,6 +56,78 @@ FROM v_cpu_utilization_stats
return items;
}
+
+ ///
+ /// #2320: the attribution denominator's raw ingredients for one window — average SQL-process CPU
+ /// percent, the sample count, and the sampled span the coverage gate needs. Mirrors Darling's
+ /// GetCpuWindowAverageAsync. Windows on collection_time in TRUE UTC — deliberately NOT the
+ /// server-local sample_time the charting read above uses (the round-6 review catch):
+ /// GetTimeRangeServerLocal leans on the GLOBAL ambient ServerTimeHelper.UtcOffsetMinutes, which
+ /// tracks whichever UI tab was last selected rather than the server this method was asked about,
+ /// so in a multi-server install the denominator's window could cover a different real-world
+ /// period than the numerator's. The stats numerator windows collection_time in UTC; so does this.
+ ///
+ public async Task<(double? AvgSqlCpuPercent, int Samples, double SpanHours)> GetCpuWindowAverageAsync(int serverId, int hoursBack)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ var endUtc = DateTime.UtcNow;
+ var startUtc = endUtc.AddHours(-hoursBack);
+
+ /* Span rides along for the cadence-agnostic coverage gate — mirrors Darling, including the
+ IS NOT NULL guard: COUNT and span must rest on the same rows the average does. */
+ command.CommandText = @"
+SELECT
+ AVG(sqlserver_cpu_utilization),
+ COUNT(*),
+ COALESCE(EXTRACT(EPOCH FROM (MAX(collection_time) - MIN(collection_time))) / 3600.0, 0)
+FROM v_cpu_utilization_stats
+WHERE server_id = $1
+AND collection_time >= $2
+AND collection_time <= $3
+AND sqlserver_cpu_utilization IS NOT NULL";
+
+ 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 (null, 0, 0);
+ }
+
+ var avg = reader.IsDBNull(0) ? (double?)null : reader.GetDouble(0);
+ var samples = reader.IsDBNull(1) ? 0 : Convert.ToInt32(reader.GetValue(1), System.Globalization.CultureInfo.InvariantCulture);
+ var spanHours = reader.IsDBNull(2) ? 0 : Convert.ToDouble(reader.GetValue(2), System.Globalization.CultureInfo.InvariantCulture);
+ return (avg, samples, spanHours);
+ }
+
+ ///
+ /// #2320: the server's core count for the denominator — the same Azure-aware
+ /// COALESCE(vcore_count, cpu_count) read the FinOps utilization CTE uses. Null when properties
+ /// were never collected; the caller omits the ratio then.
+ ///
+ public async Task GetLatestCpuCountAsync(int serverId)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ command.CommandText = @"
+SELECT COALESCE(vcore_count, cpu_count)
+FROM v_server_properties
+WHERE server_id = $1
+ORDER BY collection_time DESC
+LIMIT 1";
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ var result = await command.ExecuteScalarAsync();
+ if (result is null || result is DBNull)
+ {
+ return null;
+ }
+
+ var cores = Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture);
+ return cores > 0 ? cores : null;
+ }
}
public class CpuUtilizationRow
diff --git a/PerformanceMonitor.Common/CpuAttribution.cs b/PerformanceMonitor.Common/CpuAttribution.cs
new file mode 100644
index 000000000..e389290ab
--- /dev/null
+++ b/PerformanceMonitor.Common/CpuAttribution.cs
@@ -0,0 +1,95 @@
+/*
+ * 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;
+
+namespace PerformanceMonitor.Common;
+
+///
+/// #2320: the denominator the top-CPU rankings never handed the caller. A ranking that explains 10%
+/// of the box reads exactly like one that explains 90% unless the response says which — pre-#2290
+/// the reads explained ~10% of one production instance and nothing said so, and the Datadog
+/// disagreement on #2235 died the moment someone divided its worker_time sum by the box's available
+/// CPU-seconds and got 137%. This computes that division for our own output, from series both
+/// stores already collect (cpu_utilization sampling and server_properties.cpu_count).
+///
+public static class CpuAttribution
+{
+ ///
+ /// The minimum fraction of the WINDOW the utilization samples must span before the ratio is
+ /// worth reporting. Span, deliberately not a count-per-minute expectation (the review catch):
+ /// the collector's cadence is user-configurable per server, so any assumed frequency would
+ /// permanently disqualify a legitimately slowed server. A series whose first and last samples
+ /// bracket at least half the window supports an average at ANY cadence; below that the
+ /// denominator extrapolates from a fragment, and the contract is to OMIT rather than fabricate
+ /// (a missing ratio is honest; a wrong one sends someone chasing 3x the CPU that existed).
+ ///
+ public const double MinSpanCoverage = 0.5;
+
+ ///
+ /// And a floor on the sample count itself: a span can be bracketed by two lonely points. Three
+ /// is the least that starts to look like a series.
+ ///
+ public const int MinSamples = 3;
+
+ /// One computed window, ready for the wire.
+ public readonly record struct CpuWindow(
+ double MeasuredSqlCpuSeconds, double AttributedCpuSeconds, double AttributedRatio, string? Note);
+
+ ///
+ /// Computes the attribution window, or null when the denominator cannot be trusted: fewer than
+ /// samples, a sampled span under of the
+ /// window, an unknown core count, or a degenerate window. Null means "omit the field", never
+ /// "zero". is last-sample minus first-sample, in hours —
+ /// cadence-agnostic on purpose.
+ ///
+ public static CpuWindow? Compute(
+ double attributedCpuMs,
+ double? avgSqlCpuPercent,
+ int samplesInWindow,
+ double observedSpanHours,
+ int? cpuCount,
+ double windowHours)
+ {
+ if (avgSqlCpuPercent is not double avgPct
+ || samplesInWindow < MinSamples
+ || cpuCount is not int cores
+ || cores <= 0
+ || windowHours <= 0)
+ {
+ return null;
+ }
+
+ if (observedSpanHours < windowHours * MinSpanCoverage)
+ {
+ return null;
+ }
+
+ var measuredSeconds = avgPct / 100.0 * cores * windowHours * 3600.0;
+ if (measuredSeconds <= 0)
+ {
+ /* A box whose SQL CPU averaged a hard zero across a covered window: the ratio would
+ divide by zero, and "you used none and attributed some" is better said by omission
+ plus the raw numbers the caller already has. */
+ return null;
+ }
+
+ var attributedSeconds = attributedCpuMs / 1000.0;
+ var ratio = attributedSeconds / measuredSeconds;
+
+ var note = ratio switch
+ {
+ < 0.5 => FormattableString.Invariant(
+ $"the returned rows explain {ratio * 100:F0}% of the SQL CPU this window actually burned — the remainder lives below the ranking cut, in plans evicted between snapshots, and in uncached work"),
+ > 1.1 => "attributed CPU exceeds the measured total — the two series disagree at window edges (sampling skew); treat as ~100%",
+ _ => null,
+ };
+
+ return new CpuWindow(measuredSeconds, attributedSeconds, ratio, note);
+ }
+}