Report cpu_window on the top-CPU reads: the attribution denominator (#2320) - #2323
Report cpu_window on the top-CPU reads: the attribution denominator (#2320)#2323erikdarlingdata wants to merge 10 commits into
Conversation
| var (avgSqlCpu, cpuSamples) = await DarlingDataReader.GetCpuWindowAverageAsync( | ||
| postgres, resolved.ServerId, windowStart, now); | ||
| var cores = await DarlingDataReader.GetLatestCpuCountAsync(postgres, resolved.ServerId); |
There was a problem hiding this comment.
Correctness: a failure fetching cpu_window now fails the whole tool call, not just the new field.
rows (the query-stats data) has already been fetched successfully at this point, and the "unavailable" empty-result case is handled above. But GetCpuWindowAverageAsync/GetLatestCpuCountAsync run inside the same try with no local guard — if either throws (transient connection blip, timeout, a lock on cpu_utilization_stats/server_properties, etc.), the outer catch at the bottom of this method turns what would have been a perfectly good get_top_queries_by_cpu response into a hard error, even though none of the actual query-stats data was at fault.
That's the opposite of this feature's stated design ("omitted, never fabricated" — cpu_window: null on any data problem). An exception while gathering the denominator should degrade the same way a legitimately-missing denominator does (attribution = null), not take down rows that were already successfully retrieved. Same pattern recurs at the get_top_procedures_by_cpu call site below (~line 590), and mirrored in Lite's McpQueryTools.cs.
Suggest wrapping just the attribution fetch/compute in its own try/catch (log-and-continue with attribution = null) rather than letting it share fate with the row fetch.
There was a problem hiding this comment.
Both catches taken in 663638f, as one helper per SKU (TryComputeCpuWindowAsync): the two reads run under Task.WhenAll (they're independent — one round-trip of latency, not two) inside a try/catch that collapses any failure to a null cpu_window — the rows are already fetched, and a data problem in the DENOMINATOR is precisely the omitted-never-fabricated case this feature documents. Lite gets the identical shape (its reads each open their own DuckDB connection, so the concurrency is safe). Covers the Lite twin comment and the Task.WhenAll note in the same commit.
| var (avgSqlCpu, cpuSamples) = await dataService.GetCpuWindowAverageAsync(resolved.ServerId, hours_back); | ||
| var cores = await dataService.GetLatestCpuCountAsync(resolved.ServerId); |
There was a problem hiding this comment.
Correctness: same issue as the Darling counterpart — GetCpuWindowAverageAsync/GetLatestCpuCountAsync run unguarded inside the outer try, sharing fate with the already-successful rows fetch above. An exception here (e.g. a transient DuckDB read error on v_cpu_utilization_stats/v_server_properties) fails the entire get_top_queries_by_cpu response instead of just omitting cpu_window, which contradicts the "omitted, never fabricated" contract this feature documents for data problems — an exception isn't a data problem the caller should see as a fetch failure. Same pattern at the procedures call site (~line 146-147).
| var (avgSqlCpu, cpuSamples) = await DarlingDataReader.GetCpuWindowAverageAsync( | ||
| postgres, resolved.ServerId, windowStart, now); | ||
| var cores = await DarlingDataReader.GetLatestCpuCountAsync(postgres, resolved.ServerId); |
There was a problem hiding this comment.
Minor perf: these two reads (GetCpuWindowAverageAsync, GetLatestCpuCountAsync) are independent of each other and could run concurrently via Task.WhenAll instead of two sequential awaits — saves one Postgres round-trip of latency on every get_top_queries_by_cpu/get_top_procedures_by_cpu call. Same opportunity in Lite's McpQueryTools.cs. Not a big deal at this scale, just a free win since neither query depends on the other's result.
|
Reviewed the Left two inline notes:
Nothing else stood out — window math ( |
…urrently (review finds)
| var (avgSqlCpu, samples) = averageTask.Result; | ||
| return CpuAttribution.Compute(attributedCpuMs, avgSqlCpu, samples, coresTask.Result, hoursBack); | ||
| } | ||
| catch (Exception) |
There was a problem hiding this comment.
catch (Exception) here swallows everything — a bad connection string, a typo in the SQL, a schema mismatch, an OOM — with no log line anywhere. The rest of these tools surface exceptions to the caller via McpHelpers.FormatError, so this is the one path in the file where an actual bug becomes permanently invisible.
That cuts against the feature's own stated contract: "a missing ratio is honest; a wrong one sends someone chasing CPU that doesn't exist." A cpu_window: null caused by a real defect is not honest — it's indistinguishable from the legitimate "coverage too thin" case, and nothing in logs or the response will ever hint that the two reads are silently failing every time. Worth at least a debug-level log of the exception before returning null, so a systemic failure (e.g. server_properties never populated, or a Postgres permission issue) doesn't look identical to a healthy "not enough data yet" server forever.
Same pattern exists in Lite/Mcp/McpQueryTools.cs (line 29) — both should probably log, for parity.
There was a problem hiding this comment.
Agreed — a null born of a throw masquerading as thin coverage breaks the honesty the contract claims. e89268a: the catch now logs a warning with the exception ("if this repeats, the attribution reads have a defect"). Plumbing detail worth recording: the MCP host clears its logging providers, so an injected ILogger<T> from ITS factory would be a black hole — the host now registers the SERVICE's logger instance into the tool DI, and the two tool methods take it as an injected parameter. The web dispatch's call sites pass NullLogger.Instance with a comment saying why (that host's providers are cleared too, and both paths run the same reads, so a systemic failure still surfaces via the MCP path's logger).
| var (avgSqlCpu, samples) = averageTask.Result; | ||
| return CpuAttribution.Compute(attributedCpuMs, avgSqlCpu, samples, coresTask.Result, hoursBack); | ||
| } | ||
| catch (Exception) |
There was a problem hiding this comment.
Same broad catch (Exception) { return null; } as the Darling twin (DarlingMcpDataTools.cs around line 450) — no log, no trace, nothing to distinguish a real bug in GetCpuWindowAverageAsync/GetLatestCpuCountAsync from a legitimately untrustworthy denominator. See the parallel comment on the Darling side for the detail; flagging here too so a fix keeps both SKUs in parity.
There was a problem hiding this comment.
Same commit — Lite's catch logs through AppLogger.Warn (the app's rotating file logger), same wording, parity kept.
ReviewReviewed the What checks out:
One finding posted inline (both files, for parity): Nothing else stood out — no missing-index suggestions (per instructions), no secrets/network/process concerns, and I didn't find other Lite/Darling drift beyond the one item above. |
| logger), so a resolved ILogger here would be a black hole anyway — NullLogger states that | ||
| honestly. The MCP host injects the SERVICE's logger, and both paths run the same reads, so | ||
| a systemic attribution failure still surfaces there. */ | ||
| ["get_top_procedures_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopProceduresByCpu(pg, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name")), |
There was a problem hiding this comment.
NullLogger.Instance silently swallows every warning TryComputeCpuWindowAsync logs when a cpu_window computation throws — which is exactly the "review find" this PR's third commit set out to fix ("Log the degrade-to-null path so a defect can't hide as thin coverage"). That fix only reaches the MCP path.
The comment justifies this by saying a DI-resolved ILogger here "would be a black hole anyway" because DarlingMcpHostService/the web builder calls builder.Logging.ClearProviders(). That's true for a logger resolved from the web host's own ASP.NET DI container, but DarlingWebHostService (the class that owns this web host) has its own working ILogger<DarlingWebHostService> _logger field that's used throughout the file for LogWarning/LogError/LogCritical and is unaffected by ClearProviders(). The MCP host solves this identically for its own path — DarlingMcpHostService.cs registers builder.Services.AddSingleton<ILogger>(_logger) so the service's real logger flows into TryComputeCpuWindowAsync. The web host never threads _logger through MapAll → BuildReadDispatch, so the exact same fix wasn't applied here.
Net effect: a systemic defect in the attribution reads (the scenario this logging was added to catch) is fully invisible when triggered via GET /api/read/get_top_queries_by_cpu or get_top_procedures_by_cpu — it degrades to cpu_window: null with no trace anywhere, forever. Suggest passing _logger into DarlingWebEndpoints.MapAll and threading it to these two dispatch entries instead of NullLogger.Instance.
There was a problem hiding this comment.
The NullLogger call sites are gone entirely — e1f35d1 replaced parameter injection with a host-set static ambient (DegradeLogger), so the web dispatch reverted to its original shape and logs through the same sink as everything else.
| { | ||
| /* 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.Message}"); |
There was a problem hiding this comment.
This uses AppLogger.Warn(source, message), which only takes a string — there's no overload to attach ex itself, so this call captures only ex.Message and discards the stack trace and any inner exceptions. That undercuts the comment's own stated goal ("if this repeats, the attribution reads have a defect") — a repeating failure with no stack trace is much harder to root-cause than one with it.
AppLogger already has Error(string source, string message, Exception? ex = null) specifically for this — it logs the message, the full stack trace, and recursively unwraps inner/aggregate exceptions. It's the only other exception-logging call site in Lite/Mcp/ (McpHostService.cs:143), so Warn with a message-only interpolation is a departure from the established pattern here, and also drops fidelity relative to Darling's mirror (logger.LogWarning(ex, "...", serverId), which preserves the full exception object for whatever sink is attached).
Suggest AppLogger.Error("McpQueryTools", "cpu_window computation failed — omitting the field; if this repeats, the attribution reads have a defect.", ex) instead.
There was a problem hiding this comment.
Taken in 4733c03 — AppLogger gains Warn(source, message, Exception) sharing Error's full emission (stack, recursive inners, aggregates) via an extracted level-parameterized helper, so the severity stays truthful AND the diagnosis keeps its stack.
Review: cpu_window attribution denominator (#2320)Reviewed the shared Two inline findings, both about the round-2 review fix ("log the degrade-to-null path so a defect can't hide as thin coverage") not fully reaching both entry points:
Neither is a data-correctness bug (the field itself still degrades to No Lite/Darling parity drift found beyond the above — field names, rounding, and JSON shape ( |
| /* #2320: the web host's logging providers are cleared (its lifecycle logs ride the service | ||
| logger), so a resolved ILogger here would be a black hole anyway — NullLogger states that | ||
| honestly. The MCP host injects the SERVICE's logger, and both paths run the same reads, so | ||
| a systemic attribution failure still surfaces there. */ | ||
| ["get_top_procedures_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopProceduresByCpu(pg, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name")), | ||
| ["get_top_queries_by_cpu"] = (c, pg, an) => DarlingMcpDataTools.GetTopQueriesByCpu(pg, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, Server(c), Hours(c, 24), Rows(c, "top", 20), Str(c, "database_name"), QueryBool(c, "parallel_only", false), QueryInt(c, "min_dop", null, 0)), |
There was a problem hiding this comment.
The "systemic attribution failure still surfaces there [via MCP]" premise doesn't hold: mcp.enabled is OFF by default (see DarlingMcpHostService.cs's class doc: "Gated by darling.json's mcp.enabled (default OFF...)"). On a default install, the web dashboard's /api/read/* mirror is the only path that ever calls GetTopQueriesByCpu/GetTopProceduresByCpu, and NullLogger.Instance here means a systemic defect in TryComputeCpuWindowAsync degrades to cpu_window: null with zero log trace anywhere, on every call, forever — exactly the failure mode the "Logged, not silent (the round-2 review catch)" comment in DarlingMcpDataTools.cs was added to prevent.
Also, the "a resolved ILogger here would be a black hole anyway" reasoning conflates two different loggers: DarlingWebHostService has its own working _logger field (ILogger<DarlingWebHostService>, injected from the outer host, not the inner builder whose providers get cleared) — the exact same shape as DarlingMcpHostService._logger, which is threaded through via AddSingleton<ILogger>(_logger) a few lines away in DarlingMcpHostService.cs. The same fix is available here: thread _logger from DarlingWebHostService through MapAll(app, postgres, logger) → BuildReadDispatch(logger) instead of hardcoding NullLogger.Instance.
There was a problem hiding this comment.
Sharp catch, and it SURVIVED the redesign in a mutated form: with the ambient logger set only by the MCP host, a default install (mcp.enabled OFF) would still have been silent on the web-only path. 4733c03 sets DegradeLogger in DarlingWorker's constructor — the one component that always runs — with the mcp-off rationale in the comment; the MCP host's assignment stays as a harmless second writer of the same sink family.
ReviewFocused on the new Verified as correct:
One issue flagged inline: in No other Lite/Darling parity drift found — the two SKUs' tool bodies, JSON envelope shapes, and error-handling/logging idioms (static |
|
Reviewed this PR (correctness, Lite/Darling parity, security, performance). Summary: no blocking issues found. Things I specifically checked and verified correct:
No missing-index suggestions per the repo's convention (none applicable here anyway — no new SQL Server DDL/queries against user tables). |
…ack in Lite's warn (review finds)
| return null; | ||
| } | ||
|
|
||
| var expectedSamples = windowHours * 60.0; |
There was a problem hiding this comment.
expectedSamples hardcodes a 1-sample-per-minute cadence (windowHours * 60.0), but cpu_utilization's collection frequency is user-configurable per server — Lite's ScheduleManager (Lite/Services/ScheduleManager.cs:146, :578) lets a user set FrequencyMinutes for any collector including cpu_utilization (default 1, but changeable), and Darling's StoreConfigProvider.ResolveSchedule reads a config_collector_schedules override for the same purpose.
If a server's cpu_utilization cadence is slowed to, say, 5 minutes, samplesInWindow will permanently sit at ~1/5 of expectedSamples, so MinSampleCoverage (0.5) is never met and cpu_window silently and permanently disappears for that server — even though collection is perfectly healthy at its configured cadence. That's a real usability regression for exactly the case ScheduleManager exists to support (reducing collection overhead on busy servers), and it fails silently (per the "omit, never fabricate" design) so nothing will surface it to the user.
Worth deriving the expected-sample baseline from the server's actual configured cadence for cpu_utilization (threaded through from ScheduleManager/config_collector_schedules) rather than a hardcoded 60/hour, or at minimum noting the limitation in the tool description so a customized cadence doesn't read as "attribution unsupported."
There was a problem hiding this comment.
Right — any assumed frequency bakes today's default into the gate and permanently disqualifies a legitimately slowed server. f63893a makes the gate cadence-agnostic: the readers return the sampled SPAN (MAX minus MIN sample time) alongside avg+count, and Compute now requires the span to cover ≥ half the window (MinSpanCoverage) plus a bare MinSamples = 3 floor so two lonely points can't bracket a wide span. A 5-minute-cadence server (24 samples spanning a full 2h window) now qualifies exactly like a 1-minute one — pinned in both suites, with the slow-cadence case named as the review catch.
Review summaryReviewed the diff (shared One correctness edge case worth a look, left as an inline comment on Everything else checked out: no missing-index DMV suggestions applicable (no T-SQL changed in this PR — it's all C#), no secrets/file/process/network additions, and the two independent reads per tool call run concurrently via 🤖 Generated with Claude Code |
| { | ||
| using var connection = await OpenConnectionAsync(); | ||
| using var command = connection.CreateCommand(); | ||
| var (startTime, endTime) = GetTimeRangeServerLocal(hoursBack, null, null); |
There was a problem hiding this comment.
Correctness / Lite-Darling parity drift: cpu_window's two halves can be windowed over different real-world time spans in Lite.
GetCpuWindowAverageAsync windows sample_time via GetTimeRangeServerLocal(hoursBack, null, null), which resolves serverNow from the global ambient ServerTimeHelper.UtcOffsetMinutes — set only when a WPF tab is manually selected (MainWindow.xaml.cs ServerTabControl_SelectionChanged), defaulting to the desktop machine's own local timezone if no tab has ever been switched. It is not derived from the serverId parameter this method receives.
Meanwhile the numerator, GetTopQueriesByCpuAsync/GetTopProceduresByCpuAsync, windows collection_time via GetTimeRange — true UTC, independent of any ambient state (ServerManager.GetConnectionStatus(serverId).UtcOffsetMinutes is the per-server value that actually exists but isn't used here).
ServerResolver.Resolve (Lite/Mcp/ServerResolver.cs) picks the target server purely by server_name/single-enabled-server, with zero connection to which UI tab happens to be active. So for any multi-server install where the MCP-queried server's timezone differs from whichever tab is currently selected (or the app hasn't had a tab manually clicked since launch), measured_sql_cpu_seconds is computed over a sample_time window shifted by the wrong UTC offset relative to the attributed_cpu_seconds window — producing a cpu_window/attributed_ratio that silently compares two different real-world time periods. No exception is thrown, so the TryComputeCpuWindowAsync try/catch safety net doesn't catch it; it just produces a plausible-looking but wrong ratio, which is precisely the failure mode (#2235-style) this whole feature was built to eliminate.
Darling doesn't have this problem — DarlingDataReader.GetCpuWindowAverageAsync windows on collection_time (true UTC) for both series consistently, so the two apps' correctness guarantees for cpu_window diverge here.
Worth resolving the server's own UTC offset (e.g., via ServerManager.GetConnectionStatus(serverId)) instead of reading the ambient ServerTimeHelper.UtcOffsetMinutes, the same way the numerator's query avoids the ambient state entirely by using true UTC.
There was a problem hiding this comment.
Confirmed and fixed in 0ac0625, by removing the offset dance rather than parameterizing it: v_cpu_utilization_stats also carries collection_time (Lite's own AnomalyDetector already windows it against DateTime.UtcNow), so the denominator read now windows collection_time in TRUE UTC — the same clock the stats numerator uses — and the span is measured on the same column. No ambient state, no per-server offset plumbing, and both halves of the ratio cover the same real-world period by construction. The charting read above keeps its server-local sample_time window untouched — that one is per-tab by design.
Review summaryReviewed the What's solid:
One correctness/parity issue found, posted inline on No missing-index DMV suggestions, no other Lite/Darling drift, no security concerns beyond the above. |
… offset (review find)
|
|
||
| ### 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 (no samples, coverage under half of one-per-minute, unknown core count, hard-zero average): omitted, never fabricated. Shared CpuAttribution math, decision table pinned identically in both suites. |
There was a problem hiding this comment.
Nit: this entry's coverage description is stale relative to the shipped gate. It says the object is omitted on "coverage under half of one-per-minute", but PerformanceMonitor.Common/CpuAttribution.cs no longer uses a count-per-minute expectation at all — that was replaced (per the code's own "round-6 review catch" comments) with a cadence-agnostic span check: samples must span at least half the window (MinSpanCoverage = 0.5) and there must be at least MinSamples = 3 of them. A server whose cpu_utilization cadence was slowed to e.g. 5 minutes now correctly still qualifies, which is the opposite of what this changelog line implies. Worth updating the wording so the changelog doesn't describe a gate the code doesn't have.
There was a problem hiding this comment.
0b535b4 — the entry now describes the shipped gate (three-sample floor + span under half the window, explicitly cadence-agnostic).
|
Reviewed the final diff (base
One documentation nit posted inline: Nothing else rose to the level of a blocking finding. |
| const string sql = """ | ||
| SELECT | ||
| AVG(sqlserver_cpu_utilization)::float8, | ||
| COUNT(*)::int, |
There was a problem hiding this comment.
COUNT(*) counts every row in the window regardless of whether sqlserver_cpu_utilization is NULL — but AVG(sqlserver_cpu_utilization) silently skips NULLs. The column is nullable in the schema (cpu_utilization_stats.sqlserver_cpu_utilization integer, no NOT NULL), so Samples here can overstate how many actual readings back the average.
That matters because Samples is exactly what CpuAttribution.MinSamples (and, indirectly, the caller's trust in the average) is gating on. If a stretch of collection produced rows with a non-null collection_time but a null sqlserver_cpu_utilization (e.g. a partial collector failure that still logged a heartbeat row), this reports a "well-supported" sample count while the average is actually backed by far fewer real readings — exactly the "omit rather than fabricate" failure mode this whole feature exists to prevent.
Today's single collector (CpuUtilizationCollector) happens to always populate a non-null value, so this is dormant rather than live — but the schema allows it, and the coverage gate should count what it claims to count. Suggest COUNT(sqlserver_cpu_utilization) instead of COUNT(*). Same issue in the Lite mirror (Lite/Services/LocalDataService.Cpu.cs, GetCpuWindowAverageAsync).
There was a problem hiding this comment.
f652660 — fixed with one predicate instead of a COUNT-column swap: AND sqlserver_cpu_utilization IS NOT NULL in the WHERE, so COUNT, the SPAN, and the AVG all rest on exactly the same rows (COUNT(column) alone would still have let NULL-valued rows stretch the span).
| command.CommandText = @" | ||
| SELECT | ||
| AVG(sqlserver_cpu_utilization), | ||
| COUNT(*), |
There was a problem hiding this comment.
Same COUNT(*) vs COUNT(sqlserver_cpu_utilization) issue as the Darling mirror (DarlingDataReader.GetCpuWindowAverageAsync) — sqlserver_cpu_utilization is nullable, and COUNT(*) doesn't reflect how many rows actually contributed to the AVG. See the note on the Darling side for the full scenario; flagging here too since both readers would need the fix to stay in parity.
There was a problem hiding this comment.
Same commit, same predicate on the Lite mirror.
| public void ALowRatioCarriesTheRemainderNote() | ||
| { | ||
| var window = CpuAttribution.Compute(3_000_000, FieldAvgPct, CoveredSamples, FullSpan, FieldCores, FieldHours); | ||
|
|
There was a problem hiding this comment.
This pins the pure CpuAttribution.Compute() math well, but nothing in either suite exercises the actual wiring that produces cpu_window through the real tool call — grep -rn "cpu_window" across the repo only matches the two production files (DarlingMcpDataTools.cs, McpQueryTools.cs), never a test. GetCpuWindowAverageAsync/GetLatestCpuCountAsync (both apps) are likewise never referenced from a test.
Concretely, DarlingMcpDataToolsTests' full-lifecycle test plants only one cpu_utilization_stats row (PlantCpuAsync), which is below CpuAttribution.MinSamples (3) — so cpu_window is always null on that path, and the test can't tell whether the SQL, unit conversion (µs→ms→s), or JSON field wiring is actually correct. A bug in any of those (wrong column, wrong table, a units slip) would ship silently since TryComputeCpuWindowAsync swallows all exceptions into "omit the field."
Worth adding at least one test per app that plants ≥3 CPU samples + a server_properties/v_server_properties row and asserts the populated cpu_window object's actual values (measured/attributed/ratio) round-trip through get_top_queries_by_cpu or get_top_procedures_by_cpu.
There was a problem hiding this comment.
Fair — and the vcore COALESCE was exactly the kind of thing that would have hidden behind the degrade catch if the column hadn't existed. f652660 wires it: the gated live-PG round-trip now plants three CPU samples spanning 23h (meeting the gate's floors for the default window) and asserts through the REAL tool call that cpu_window is non-null, measured_sql_cpu_seconds equals the planted 40% × cpu_count 16 × 24h exactly, the ratio is below half, and the remainder note fires. A reader-SQL typo now fails the suite instead of nulling silently.
Review summaryReviewed the Two things worth a look, left as inline comments:
Neither blocks correctness of what's shipped today, but both are worth addressing given how much rigor the rest of the PR puts into exactly this kind of edge case. |
…e live test (review finds)
| /// 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. | ||
| /// </summary> | ||
| public async Task<(double? AvgSqlCpuPercent, int Samples, double SpanHours)> GetCpuWindowAverageAsync(int serverId, int hoursBack) |
There was a problem hiding this comment.
Parity gap: no end-to-end test for these new reads on the Lite side.
Darling added an integration test (DarlingMcpDataToolsTests.cs, the GetTopQueriesByCpu assertions around cpu_window) that exercises GetCpuWindowAverageAsync/GetLatestCpuCountAsync against the real Postgres schema, with the comment explicitly noting why: "a column typo would hide behind the degrade-to-null catch forever, which is why this exists."
That failure mode applies identically here — GetCpuWindowAverageAsync/GetLatestCpuCountAsync query v_cpu_utilization_stats/v_server_properties directly, and any mismatch (column rename, view change) would be swallowed by TryComputeCpuWindowAsync's catch block in McpQueryTools.cs and silently degrade to cpu_window: null, logged only. Lite already has the fixture infrastructure to catch this (SharedDuckDbFixture, used by McpTopQueriesDistinctTextsTests.cs to call McpQueryTools.GetTopQueriesByCpu end-to-end), but no equivalent cpu_window assertion was added anywhere in Lite.Tests. Only the pure-math CpuAttributionTests.cs (identical in both suites) covers this feature on the Lite side — the wiring itself is untested.
Worth adding a planted-CPU-samples case to an existing DuckDB-fixture-backed test (or a new one) that asserts cpu_window actually computes through the real tool call, mirroring what Darling did.
Review summaryWent through the shared Overall: solid. The core design (omit-never-fabricate, span-based coverage gate that's cadence-agnostic, identical decision table pinned in both One gap flagged inline: Darling added an end-to-end integration test asserting No correctness bugs, security issues, or other parity drift found. |
What
get_top_queries_by_cpuandget_top_procedures_by_cpu(both apps) gain acpu_windowobject:measured_sql_cpu_seconds(the collected utilization series' average × core count × window),attributed_cpu_seconds(the returned rows' windowed total),attributed_ratio, and a conditional note. Twice earned on #2235: pre-#2290 the 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% — this field is that division as a first-class output.How
PerformanceMonitor.Common.CpuAttribution): the denominator, the ratio, and two notes — under-half explains where the remainder lives (below the ranking cut, evicted plans, uncached work); past 1.1 is the impossibility/skew detector. Decision table pinned identically in both suites, with Per-query CPU attribution unusable on a plan-churning instance: reads see 18 execs where Datadog sees 43% of the box; literal fragmentation defeats top-N #2235's field numbers (18% × 8 cores × 2h → 10,368 core-seconds; the 14,229s claim → ratio 1.37 → skew note) as the load-bearing cases.MinSampleCoverage), unknown core count, hard-zero average. A missing ratio is honest; a wrong one sends someone chasing CPU that doesn't exist.cpu_utilization_statson collection_time; latestCOALESCE(vcore_count, cpu_count)fromserver_properties— the Azure-aware read the FinOps CTE already uses). Lite: mirrors viaLocalDataServiceagainstv_cpu_utilization_statson server-local sample_time (that table's clock, same as its existing CPU read) andv_server_properties.Verification
Local harness 10/10 over the shared math including both field cases; both SKUs and both test suites build clean. The mirrored
CpuAttributionTestsrun in CI's Windows build.🤖 Generated with Claude Code