From 8379d3d920c39386de909c584ab090cdc025aad6 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 30 Sep 2026 19:30:20 -0400
Subject: [PATCH 1/7] Perfmon rate counters show a per-second figure in the web
grid, the web trend chart and both MCP tools
---
.../Darling.Tests/DarlingMcpDataToolsTests.cs | 6 +
.../DarlingMcpDataTools.txt | 2 +-
.../Darling.Tests/McpToolsListBudgetTests.cs | 4 +-
.../PerfmonCounterTypeRungTests.cs | 14 +-
.../PerfmonPerSecondPayloadTests.cs | 81 +++++++++
.../WebPerfmonPerSecondBehaviourTests.cs | 123 +++++++++++++
Darling/Darling.Tests/web-perfmon-harness.mjs | 172 ++++++++++++++++++
.../Mcp/DarlingDataReader.cs | 17 +-
.../Mcp/DarlingMcpDataTools.cs | 25 ++-
.../Mcp/DarlingMcpTrendTools.cs | 2 +-
.../wwwroot/js/pages/server-tabs.js | 34 +++-
Lite.Tests/DeltaSeriesShapingTests.cs | 21 +++
.../McpToolsListBudget/McpPerfmonTools.txt | 2 +-
Lite.Tests/McpToolsListBudgetTests.cs | 4 +-
Lite.Tests/PerfmonCounterTypeTests.cs | 77 +++++++-
Lite/Mcp/McpPerfmonTools.cs | 23 +--
Lite/Services/LocalDataService.Perfmon.cs | 10 +-
.../DeltaSeriesShaping.cs | 13 ++
.../Mcp/TrendPayloads.cs | 57 +++++-
19 files changed, 626 insertions(+), 61 deletions(-)
create mode 100644 Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs
create mode 100644 Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs
create mode 100644 Darling/Darling.Tests/web-perfmon-harness.mjs
diff --git a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
index 18f5e4b1d..a6162ab1f 100644
--- a/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpDataToolsTests.cs
@@ -609,6 +609,12 @@ public void PerfmonSql_LatestSnapshot_ValueAndDelta()
Assert.Contains("cntr_value", sql, StringComparison.Ordinal);
Assert.Contains("delta_cntr_value", sql, StringComparison.Ordinal);
Assert.Contains("MAX(collection_time)", sql, StringComparison.Ordinal);
+
+ /* The interval a rate row's per_second divides by, selected LAST (ordinal 6, after cntr_type) because the
+ reader reads by ordinal. */
+ var interval = sql.IndexOf("sample_interval_seconds", StringComparison.Ordinal);
+ Assert.True(interval > sql.IndexOf("cntr_type", StringComparison.Ordinal), "sample_interval_seconds must follow cntr_type");
+ Assert.True(interval < sql.IndexOf("FROM v_perfmon_stats", StringComparison.Ordinal), "sample_interval_seconds must be a selected column");
}
[Fact]
diff --git a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt
index 5bfb2e121..1a99e0e61 100644
--- a/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt
+++ b/Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt
@@ -39,7 +39,7 @@ param get_memory_clerks.server_name 28
tool get_memory_stats 480
param get_memory_stats.server_name 28
-tool get_perfmon_stats 521
+tool get_perfmon_stats 579
param get_perfmon_stats.counter_name 61
param get_perfmon_stats.instance_name 57
param get_perfmon_stats.server_name 28
diff --git a/Darling/Darling.Tests/McpToolsListBudgetTests.cs b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
index ce2e06944..f835e656c 100644
--- a/Darling/Darling.Tests/McpToolsListBudgetTests.cs
+++ b/Darling/Darling.Tests/McpToolsListBudgetTests.cs
@@ -190,7 +190,9 @@ shared 32 KB budget and is not a served description either. */
// #4452 (merge): re-measured on the tree combining dev's #4442 get_read_latency addition with this
// branch's scheduler-issues tool description growth (+95). Constant set to the value
// McpToolsListBudgetTests itself measured on the merged tree, not the two deltas added by hand.
- private const int TotalCeilingBytes = 176_476;
+ // get_perfmon_stats' head names the new per_second field in its rate clause (521 -> 579), byte-identical
+ // with Lite's twin. Constant set to the value McpToolsListBudgetTests itself measured on this tree.
+ private const int TotalCeilingBytes = 176_515;
diff --git a/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs b/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
index 99fd3d629..0f8ee7c51 100644
--- a/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
+++ b/Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs
@@ -213,7 +213,8 @@ public void TheProbeCarriesThisRungsSentinel_AndAFullyMigratedStoreMapsToTheLadd
///
/// Every Darling perfmon read selects the type: the two trend reads (MCP and viewer) with the one agreed-type
/// expression, byte-identical to each other and to every copy in Lite's trend reads, and the latest-snapshot read
- /// with the row's own. The row types keep NULL as null — a gauge row stores no delta and no interval, and a 0
+ /// with the row's own, and after it the interval a rate row's per-second figure divides by, in the same order in
+ /// Lite's twin. The row types keep NULL as null — a gauge row stores no delta and no interval, and a 0
/// manufactured in their place would be #3642's fabricated zero on a level that has no delta.
///
[Fact]
@@ -230,8 +231,8 @@ public void EveryDarlingPerfmonRead_SelectsTheType_AndTheRowsKeepNullAsNull()
Assert.Equal(3, Regex.Matches(lite, Regex.Escape(AgreedTypeExpression)).Count);
var latest = DarlingDataReader.LatestPerfmonStatsSql.Replace("\r\n", "\n", StringComparison.Ordinal);
- Assert.Contains("collection_time,\n cntr_type\nFROM v_perfmon_stats", latest, StringComparison.Ordinal);
- Assert.Contains("collection_time,\n cntr_type\nFROM v_perfmon_stats", lite.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal);
+ Assert.Contains("collection_time,\n cntr_type,\n sample_interval_seconds\nFROM v_perfmon_stats", latest, StringComparison.Ordinal);
+ Assert.Contains("collection_time,\n cntr_type,\n sample_interval_seconds\nFROM v_perfmon_stats", lite.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal);
/* #4476: both the MCP and the Viewer twins' per-collection SUMs exclude a Wait Statistics isolated
single-sample artifact — FILTER (WHERE NOT is_artifact) — while the MAX interval and the MIN=MAX
@@ -456,6 +457,7 @@ alignment against TrendBuckets.OriginSql. */
Assert.All(points, p => Assert.Equal(JsonValueKind.Null, p.GetProperty("delta_value").ValueKind));
Assert.All(points, p => Assert.Equal(JsonValueKind.Null, p.GetProperty("sample_interval_seconds").ValueKind));
Assert.Equal(8_000_000, points[1].GetProperty("value").GetInt64());
+ Assert.All(points, p => Assert.False(p.TryGetProperty("per_second", out _)));
}
using (var trend = JsonDocument.Parse(await DarlingMcpTrendTools.GetPerfmonTrend(postgres, "Batch Requests/sec", ServerName, bucket_minutes: 1)))
@@ -465,6 +467,9 @@ alignment against TrendBuckets.OriginSql. */
Assert.Equal(0, points[0].GetProperty("sample_interval_seconds").GetInt64());
Assert.Equal(900, points[1].GetProperty("delta_value").GetInt64());
Assert.Equal(300, points[1].GetProperty("sample_interval_seconds").GetInt64());
+ /* The rate a reader means: the first point's delta was not knowable, the second's is 900 over 300 s. */
+ Assert.Equal(JsonValueKind.Null, points[0].GetProperty("per_second").ValueKind);
+ Assert.Equal(3.0, points[1].GetProperty("per_second").GetDouble(), precision: 6);
}
using (var trend = JsonDocument.Parse(await DarlingMcpTrendTools.GetPerfmonTrend(postgres, "Lock waits", ServerName, bucket_minutes: 1)))
@@ -480,10 +485,13 @@ alignment against TrendBuckets.OriginSql. */
var started = counters.Single(c => c.GetProperty("instance_name").GetString() == "Waits started per second");
Assert.Equal("rate", started.GetProperty("counter_kind").GetString());
Assert.Equal(70, started.GetProperty("delta_value").GetInt64());
+ /* 70 over the 300 s since the previous collection, which the latest read now selects. */
+ Assert.Equal(0.2333, started.GetProperty("per_second").GetDouble(), precision: 6);
var inProgress = counters.Single(c => c.GetProperty("instance_name").GetString() == "Waits in progress");
Assert.Equal("gauge", inProgress.GetProperty("counter_kind").GetString());
Assert.Equal(JsonValueKind.Null, inProgress.GetProperty("delta_value").ValueKind);
Assert.Equal(3, inProgress.GetProperty("value").GetInt64());
+ Assert.False(inProgress.TryGetProperty("per_second", out _));
}
bodySucceeded = true;
diff --git a/Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs b/Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs
new file mode 100644
index 000000000..8bc04089e
--- /dev/null
+++ b/Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs
@@ -0,0 +1,81 @@
+/*
+ * 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.Linq;
+using System.Text.Json;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service.Mcp;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Darling's get_perfmon_stats row, built without a store: the reader's row goes through
+/// exactly as the tool sends it. A rate counter's value is
+/// its running total, so the row also carries per_second, its delta over the seconds since the previous
+/// collection, by the rule both desktop charts plot with. Lite's tool builds the same row through the same shared
+/// builder, and PerfmonCounterTypeReadTests in Lite.Tests checks it end to end. The store half (the reader
+/// selecting the interval) is DarlingMcpDataToolsSurfaceAndSqlTests.PerfmonSql_LatestSnapshot_ValueAndDelta
+/// here and PerfmonCounterTypeLivePostgresTests against a real store.
+///
+public sealed class PerfmonPerSecondPayloadTests
+{
+ private static JsonElement Payload(string counter, long value, long? delta, int? type, int? interval, string instance = "") =>
+ JsonDocument.Parse(JsonSerializer.Serialize(
+ DarlingMcpDataTools.PerfmonRowPayload(new DarlingDataReader.PerfmonRow(counter, instance, value, delta, type, interval)),
+ McpHelpers.JsonOptions)).RootElement;
+
+ [Fact]
+ public void ARateRow_CarriesItsDeltaOverTheStoredInterval_BesideTheRunningTotal()
+ {
+ var row = Payload("Batch Requests/sec", 11_641, 66, PerfmonCounterTypes.PerfCounterBulkCount, 300);
+
+ Assert.Equal(0.22, row.GetProperty("per_second").GetDouble(), precision: 10);
+ Assert.Equal(11_641, row.GetProperty("value").GetInt64());
+ Assert.Equal(66, row.GetProperty("delta_value").GetInt64());
+ Assert.Equal("rate", row.GetProperty("counter_kind").GetString());
+
+ /* The keys the tool published before stay where they were; per_second is added after them. */
+ Assert.Equal(
+ new[] { "counter_name", "instance_name", "value", "delta_value", "cntr_type", "counter_kind", "per_second" },
+ row.EnumerateObject().Select(p => p.Name).ToArray());
+ }
+
+ /// An interval of 0 is the calculator's "no delta was knowable" (a first collection, a counter reset,
+ /// a restart): the rate is unknown, which is null, not 0.
+ [Fact]
+ public void ARateRowWithNoKnowableDelta_SaysNull_NotZero()
+ {
+ var row = Payload("SQL Compilations/sec", 4_000, 0, PerfmonCounterTypes.PerfCounterBulkCount, 0);
+
+ Assert.Equal(JsonValueKind.Null, row.GetProperty("per_second").ValueKind);
+ }
+
+ /// A gauge's value is its reading, and an average's numerator is not a rate: neither row has the key,
+ /// so a caller never takes its absence of a rate for an unknown one.
+ [Fact]
+ public void AGaugeRow_AndAnAverageRow_HaveNoPerSecondKey()
+ {
+ var gauge = Payload("Total Server Memory (KB)", 8_000_000, null, PerfmonCounterTypes.PerfCounterLargeRawCount, null);
+ Assert.False(gauge.TryGetProperty("per_second", out _));
+ Assert.Equal(8_000_000, gauge.GetProperty("value").GetInt64());
+ Assert.Equal(JsonValueKind.Null, gauge.GetProperty("delta_value").ValueKind);
+
+ var average = Payload("Lock waits", 5_000, 40, PerfmonCounterTypes.PerfAverageBulk, 300, "Average wait time (ms)");
+ Assert.False(average.TryGetProperty("per_second", out _));
+ }
+
+ /// A row written before the type was stored is rated the way the charts rate it: by a name that says
+ /// /sec.
+ [Fact]
+ public void ARowWithNoStoredType_IsRatedByItsName()
+ {
+ Assert.Equal(0.2, Payload("Legacy Transactions/sec", 900, 60, null, 300).GetProperty("per_second").GetDouble(), precision: 10);
+ Assert.False(Payload("Legacy Counter", 120, 20, null, 300).TryGetProperty("per_second", out _));
+ }
+}
diff --git a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs
new file mode 100644
index 000000000..cec3ea05e
--- /dev/null
+++ b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs
@@ -0,0 +1,123 @@
+/*
+ * 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.ComponentModel;
+using System.Diagnostics;
+using System.Linq;
+using System.Text.Json;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// What the web Perfmon Counters panel draws, from the shipped server-tabs.js run under Node
+/// (web-perfmon-harness.mjs) against the rows get_perfmon_stats and get_perfmon_trend send.
+/// A rate counter's stored value is its running total (Batch Requests/sec 11,641 with a delta of 66 read as 11,641 a
+/// second), so the grid shows the row's per_second in a Per second column and leaves the running total out,
+/// and the trend chart draws the rate's per_second line. A gauge keeps its reading in the grid and its chart.
+/// Node is skipped when it is not installed, the way does.
+///
+public sealed class WebPerfmonPerSecondBehaviourTests
+{
+ private static bool TryRun(string scenario, out JsonElement result)
+ {
+ result = default;
+ var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "web-perfmon-harness.mjs"));
+ psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js"));
+ psi.ArgumentList.Add(scenario);
+
+ Process proc;
+ try
+ {
+ proc = Process.Start(psi)!;
+ }
+ catch (Win32Exception)
+ {
+ return false;
+ }
+
+ using (proc)
+ {
+ var error = proc.StandardError.ReadToEndAsync();
+ var output = proc.StandardOutput.ReadToEnd().Trim();
+ if (!proc.WaitForExit(20000))
+ {
+ proc.Kill(entireProcessTree: true);
+ Assert.Fail("the perfmon harness did not finish in 20 s for scenario " + scenario);
+ }
+
+ Assert.True(proc.ExitCode == 0, "the perfmon harness failed for scenario " + scenario + ": " + error.Result);
+ using var doc = JsonDocument.Parse(output);
+ result = doc.RootElement.Clone();
+ Assert.Empty(result.GetProperty("errors").EnumerateArray());
+ Assert.Empty(result.GetProperty("rejections").EnumerateArray());
+ return true;
+ }
+ }
+
+ private static string[] Strings(JsonElement array) => array.EnumerateArray().Select(e => e.GetString()!).ToArray();
+
+ private static string[] Row(JsonElement result, string counter) =>
+ result.GetProperty("rows").EnumerateArray().Select(Strings).Single(cells => cells[0] == counter);
+
+ [Fact]
+ public void TheGrid_ShowsARateCounterPerSecond_AndLeavesItsRunningTotalOut()
+ {
+ if (!TryRun("mixed", out var r)) return;
+
+ Assert.Equal(new[] { "Counter", "Instance", "Per second", "Value", "Delta" }, Strings(r.GetProperty("headers")));
+ Assert.Equal(new[] { "Batch Requests/sec", "—", "0.22", "—", "66.00" }, Row(r, "Batch Requests/sec"));
+ }
+
+ /// A rate with no knowable delta (a first collection, a counter reset, a restart) has no rate to show,
+ /// and the 0 stored beside its interval of 0 is a stand-in, not a count: no number is shown for it.
+ [Fact]
+ public void ARateRowWithNoKnowableDelta_ShowsNoNumber()
+ {
+ if (!TryRun("mixed", out var r)) return;
+
+ Assert.Equal(new[] { "SQL Compilations/sec", "—", "—", "—", "—" }, Row(r, "SQL Compilations/sec"));
+ }
+
+ /// A gauge's value is its reading, and an average's numerator keeps its value and delta: only a rate's
+ /// row changes.
+ [Fact]
+ public void AGaugeRow_AndAnAverageRow_KeepTheirNumbers()
+ {
+ if (!TryRun("mixed", out var r)) return;
+
+ Assert.Equal(new[] { "Total Server Memory (KB)", "—", "—", "8,000,000.00", "—" }, Row(r, "Total Server Memory (KB)"));
+ Assert.Equal(new[] { "Lock waits", "Average wait time (ms)", "—", "5,000.00", "40.00" }, Row(r, "Lock waits"));
+ }
+
+ [Fact]
+ public void TheTrendChart_DrawsARateCounterPerSecond()
+ {
+ if (!TryRun("mixed", out var r)) return;
+
+ var chart = Assert.Single(r.GetProperty("charts").EnumerateArray());
+ var line = Assert.Single(chart.GetProperty("series").EnumerateArray());
+ Assert.Equal("per_second", line.GetProperty("key").GetString());
+ Assert.Equal("Per second", line.GetProperty("label").GetString());
+ Assert.Equal("/s", chart.GetProperty("unit").GetString());
+ }
+
+ /// A gauge's chart is what it was: its reading, and the delta line its points leave empty.
+ [Fact]
+ public void TheTrendChart_ForAGauge_IsUnchanged()
+ {
+ if (!TryRun("gauge", out var r)) return;
+
+ var chart = Assert.Single(r.GetProperty("charts").EnumerateArray());
+ Assert.Equal(new[] { "value", "delta_value" }, chart.GetProperty("series").EnumerateArray().Select(s => s.GetProperty("key").GetString()!).ToArray());
+ Assert.Equal(new[] { "Value", "Delta" }, chart.GetProperty("series").EnumerateArray().Select(s => s.GetProperty("label").GetString()!).ToArray());
+ Assert.Equal(JsonValueKind.Null, chart.GetProperty("unit").ValueKind);
+ }
+}
diff --git a/Darling/Darling.Tests/web-perfmon-harness.mjs b/Darling/Darling.Tests/web-perfmon-harness.mjs
new file mode 100644
index 000000000..2adc0c5e6
--- /dev/null
+++ b/Darling/Darling.Tests/web-perfmon-harness.mjs
@@ -0,0 +1,172 @@
+/* Runs the web viewer's Perfmon panel (perfmonPanel in wwwroot/js/pages/server-tabs.js, with util.js and panels.js)
+ against a scripted /api/read answer and prints the grid it drew and the chart lines it asked for, as one line of
+ JSON. WebPerfmonPerSecondBehaviourTests starts it as
+ node web-perfmon-harness.mjs
+ The modules are copied into a scratch folder beside a recording stand-in for charts.js (the SVG renderer, which
+ needs a real browser), then imported. `fetch` and the DOM are stand-ins: a node tree of plain objects, and a fetch
+ that answers each URL from the scenario. Everything else is the shipped code. */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+
+const [jsDir, scenario] = process.argv.slice(-2);
+
+class FakeNode {
+ constructor(tag, text) {
+ this.tag = tag;
+ this.children = [];
+ this.attrs = {};
+ this.dataset = {};
+ this.style = {};
+ this.className = "";
+ this.text = text == null ? null : String(text);
+ this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
+ }
+ get firstChild() {
+ return this.children[0] || null;
+ }
+ appendChild(child) {
+ this.children.push(child);
+ return child;
+ }
+ removeChild(child) {
+ const i = this.children.indexOf(child);
+ if (i >= 0) this.children.splice(i, 1);
+ return child;
+ }
+ setAttribute(name, value) {
+ this.attrs[name] = String(value);
+ }
+ getAttribute(name) {
+ return name in this.attrs ? this.attrs[name] : null;
+ }
+ addEventListener() {}
+ set textContent(value) {
+ this.children = [];
+ this.text = String(value);
+ }
+ get textContent() {
+ return (this.text || "") + this.children.map((c) => c.textContent).join("");
+ }
+}
+
+globalThis.Node = FakeNode;
+globalThis.document = {
+ createElement: (tag) => new FakeNode(tag),
+ createElementNS: (ns, tag) => new FakeNode(tag),
+ createTextNode: (text) => new FakeNode("#text", text),
+};
+
+const fetches = [];
+let answer = () => ({ status: 200, body: {} });
+globalThis.fetch = async (url) => {
+ fetches.push(String(url));
+ const reply = answer(new URL(String(url), "http://viewer.test"));
+ const raw = reply.body === undefined ? "" : JSON.stringify(reply.body);
+ return { status: reply.status, ok: reply.status >= 200 && reply.status < 300, text: async () => raw };
+};
+
+const rejections = [];
+process.on("unhandledRejection", (e) => rejections.push(String(e && e.stack ? e.stack : e)));
+
+/* The modules, unchanged, with charts.js replaced by a stand-in that records the lines and the unit each chart was
+ asked to draw. */
+const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "perfmon-grid-"));
+let modules;
+try {
+ fs.mkdirSync(path.join(scratch, "pages"));
+ fs.writeFileSync(path.join(scratch, "package.json"), '{ "type": "module" }');
+ fs.copyFileSync(path.join(jsDir, "util.js"), path.join(scratch, "util.js"));
+ fs.copyFileSync(path.join(jsDir, "panels.js"), path.join(scratch, "panels.js"));
+ fs.copyFileSync(path.join(jsDir, "pages", "server-tabs.js"), path.join(scratch, "pages", "server-tabs.js"));
+ fs.writeFileSync(
+ path.join(scratch, "charts.js"),
+ 'import { el } from "./util.js";\n' +
+ "export const SERIES_COLORS = ['#111', '#222', '#333', '#444', '#555', '#666'];\n" +
+ "export const chartCalls = [];\n" +
+ "export function normalizeColor(color) { return color; }\n" +
+ "export function renderLineChart(opts) {\n" +
+ " chartCalls.push({ series: (opts.series || []).map((s) => ({ key: s.key, label: s.label })), unit: opts.unit == null ? null : opts.unit });\n" +
+ " return el('div', { class: 'chart-stub' });\n" +
+ "}\n"
+ );
+ const load = (rel) => import(pathToFileURL(path.join(scratch, rel)).href);
+ modules = {
+ util: await load("util.js"),
+ tabs: await load(path.join("pages", "server-tabs.js")),
+ charts: await load("charts.js"),
+ };
+} finally {
+ fs.rmSync(scratch, { recursive: true, force: true });
+}
+
+const data = (body) => ({ status: 200, body });
+const tool = (url) => url.pathname.replace("/api/read/", "");
+
+/* get_perfmon_stats rows as the server builds them: a rate row carries per_second (null where no delta was
+ knowable), and no other row has the key. */
+const BATCHES = { counter_name: "Batch Requests/sec", instance_name: "", value: 11641, delta_value: 66, cntr_type: 272696576, counter_kind: "rate", per_second: 0.22 };
+const COMPILES = { counter_name: "SQL Compilations/sec", instance_name: "", value: 4000, delta_value: 0, cntr_type: 272696576, counter_kind: "rate", per_second: null };
+const MEMORY = { counter_name: "Total Server Memory (KB)", instance_name: "", value: 8000000, delta_value: null, cntr_type: 65792, counter_kind: "gauge" };
+const AVERAGE = { counter_name: "Lock waits", instance_name: "Average wait time (ms)", value: 5000, delta_value: 40, cntr_type: 1073874176, counter_kind: "other" };
+
+const stats = (counters) => data({ server: "SRV1", captured_at: "2026-01-01T00:05:00.0000000", counters });
+
+const RATE_TREND = {
+ server: "SRV1",
+ counter_name: "Batch Requests/sec",
+ cntr_type: 272696576,
+ counter_kind: "rate",
+ trend: [
+ { time: "2026-01-01T00:00:00", value: 11000, delta_value: 0, sample_interval_seconds: 0, per_second: null, peak_per_second: null },
+ { time: "2026-01-01T00:05:00", value: 11641, delta_value: 66, sample_interval_seconds: 300, per_second: 0.22, peak_per_second: 0.22 },
+ ],
+ discontinuities: [],
+};
+const GAUGE_TREND = {
+ server: "SRV1",
+ counter_name: "Total Server Memory (KB)",
+ cntr_type: 65792,
+ counter_kind: "gauge",
+ trend: [{ time: "2026-01-01T00:05:00", value: 8000000, delta_value: null, sample_interval_seconds: null, peak_value: 8000000 }],
+ discontinuities: [],
+};
+
+const scenarios = {
+ // Every kind of row in one snapshot; the picker opens on the first name, Batch Requests/sec, a rate.
+ mixed: () => {
+ answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([MEMORY, BATCHES, AVERAGE, COMPILES]) : data(RATE_TREND));
+ return modules.tabs.perfmonPanel("SRV1", { hours: 24, label: "last 24 hours" });
+ },
+ // A snapshot of one gauge: its grid row and its chart are what they were.
+ gauge: () => {
+ answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([MEMORY]) : data(GAUGE_TREND));
+ return modules.tabs.perfmonPanel("SRV1", { hours: 24, label: "last 24 hours" });
+ },
+};
+
+const chosen = scenarios[scenario];
+if (!chosen) throw new Error("unknown scenario " + scenario);
+
+const root = new FakeNode("main");
+modules.util.mount(root, chosen());
+// Let both reads settle (each one is a few promise hops).
+for (let i = 0; i < 200; i++) await new Promise((r) => setTimeout(r, 0));
+
+const all = (node, tag, found = []) => {
+ if (!node || typeof node !== "object") return found;
+ if (node.tag === tag) found.push(node);
+ node.children.forEach((c) => all(c, tag, found));
+ return found;
+};
+const table = all(root, "table")[0] || null;
+
+console.log(JSON.stringify({
+ headers: table ? all(table, "th").map((th) => th.textContent) : [],
+ rows: table ? all(table, "tr").map((tr) => all(tr, "td").map((td) => td.textContent)).filter((cells) => cells.length) : [],
+ charts: modules.charts.chartCalls,
+ errors: all(root, "div").filter((n) => n.className === "strip error").map((n) => n.textContent),
+ fetches,
+ rejections,
+}));
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
index 967afc447..b684d453e 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs
@@ -194,8 +194,10 @@ public sealed record TempDbSample(
/// One perfmon counter at the latest snapshot. DeltaValue is null on a gauge row, which
/// stores no delta (V132, #3653 A7); CntrType is the DMV's type id as stored, null on a row written
- /// before the rung.
- public sealed record PerfmonRow(string CounterName, string InstanceName, long Value, long? DeltaValue, int? CntrType = null);
+ /// before the rung. SampleIntervalSeconds is the seconds the delta covers under the three-state rule
+ /// (0 = no delta knowable, null on a gauge), the denominator of the row's per-second figure.
+ public sealed record PerfmonRow(
+ string CounterName, string InstanceName, long Value, long? DeltaValue, int? CntrType = null, int? SampleIntervalSeconds = null);
/// One (database, query_hash) group's summed query-stats deltas over the window. Time
/// metrics are in microseconds (converted to ms by the tool, matching Lite).
@@ -925,8 +927,9 @@ public static async Task> GetTempDbBucketsAsync(
///
/// The latest perfmon counters — Lite's GetLatestPerfmonStatsAsync: counter_name /
/// instance_name / cntr_value / delta_cntr_value at the newest collection, with that collection's
- /// collection_time trailing (#3541 A10, published once as captured_at) and the row's
- /// stored cntr_type after it (V132). $1 server_id.
+ /// collection_time trailing (#3541 A10, published once as captured_at), the row's
+ /// stored cntr_type after it (V132), and last the sample_interval_seconds a rate row's
+ /// per-second figure divides by. $1 server_id.
///
public const string LatestPerfmonStatsSql = """
SELECT
@@ -935,7 +938,8 @@ public static async Task> GetTempDbBucketsAsync(
cntr_value,
delta_cntr_value,
collection_time,
- cntr_type
+ cntr_type,
+ sample_interval_seconds
FROM v_perfmon_stats
WHERE server_id = $1
AND collection_time = (SELECT MAX(collection_time) FROM v_perfmon_stats WHERE server_id = $1)
@@ -959,7 +963,8 @@ public static async Task> GetLatestPerfmonStatsAsync(
reader.IsDBNull(2) ? 0 : reader.GetInt64(2),
/* NULL stays NULL: a gauge row stores no delta (V132); 0 here would be a fabricated zero. */
reader.IsDBNull(3) ? null : reader.GetInt64(3),
- reader.IsDBNull(5) ? null : reader.GetInt32(5)));
+ reader.IsDBNull(5) ? null : reader.GetInt32(5),
+ reader.IsDBNull(6) ? null : reader.GetInt32(6)));
capturedAt ??= reader.GetDateTime(4);
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
index 172df01b9..b3e1f473c 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs
@@ -473,7 +473,7 @@ internal static async Task GetTempDbTrend(
}
}
- [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values (batch requests/sec, compilations/sec, deadlocks/sec, and more). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected; use get_perfmon_trend for history. counter_kind: gauge = value IS the reading, delta_value null; rate = value is cumulative, delta_value its per-interval change; other = a non-rate per-interval change; null counter_kind predates the column, classify by name (ends in /sec = rate). <> Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count and delta_value is its change over the last collection interval (get_perfmon_trend carries the sample_interval_seconds to divide it by for a per-second figure), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate).")]
+ [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values (batch requests/sec, compilations/sec, deadlocks/sec, and more). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected; use get_perfmon_trend for history. counter_kind: gauge = value IS the reading, delta_value null; rate = value is cumulative, delta_value its per-interval change, per_second that change per second (null when unknowable); other = a non-rate per-interval change; null counter_kind predates the column, classify by name (ends in /sec = rate). <> Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count (a running total, not a rate), delta_value is its change over the last collection interval, and per_second is that change divided by the interval's seconds: the counter's rate, the figure to report for it, and null when no delta was knowable (a first collection, a counter reset or a restart), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate, and carries per_second).")]
public static async Task GetPerfmonStats(
NpgsqlDataSource postgres,
[Description("Server name or display name.")] string? server_name = null,
@@ -497,18 +497,11 @@ public static async Task GetPerfmonStats(
if (!string.IsNullOrEmpty(instance_name))
filtered = filtered.Where(r => r.InstanceName.Contains(instance_name, StringComparison.OrdinalIgnoreCase));
- /* counter_kind is the stored type's three-way reading (V132, #3653 A7) through the one shared
- vocabulary; delta_value is null on a gauge because the collector writes none — the reading is
- value — and null on nothing else. Twin of Lite's McpPerfmonTools. */
- var result = filtered.Select(r => new
- {
- counter_name = r.CounterName,
- instance_name = r.InstanceName,
- value = r.Value,
- delta_value = r.DeltaValue,
- cntr_type = r.CntrType,
- counter_kind = PerfmonCounterTypes.Word(r.CntrType)
- });
+ /* One row per counter, built by the shared TrendPayloads.PerfmonLatestRow that Lite's McpPerfmonTools
+ calls too: counter_kind is the stored type's three-way reading (V132, #3653 A7), delta_value is null
+ on a gauge because the collector writes none, and a rate row adds per_second, its delta over the
+ stored interval, so the running total in value is never the only number a reader gets. */
+ var result = filtered.Select(PerfmonRowPayload);
return JsonSerializer.Serialize(new
{
@@ -523,6 +516,12 @@ public static async Task GetPerfmonStats(
}
}
+ /// One get_perfmon_stats row, built by the builder Lite's tool uses
+ /// (). A method of its own so the row can be checked without a
+ /// store.
+ internal static Dictionary PerfmonRowPayload(DarlingDataReader.PerfmonRow r) =>
+ TrendPayloads.PerfmonLatestRow(r.CounterName, r.InstanceName, r.Value, r.DeltaValue, r.SampleIntervalSeconds, r.CntrType);
+
/* ═══════════════════════════ query performance ═══════════════════════════ */
[McpServerTool(Name = "get_top_queries_by_cpu"), Description("Gets expensive cached queries from sys.dm_exec_query_stats, ranked by CPU over a window ending at as_of. Filters (database_name, parallel_only, min_dop) apply before the top-N cap: filter_applied names the floor in force, and an empty page under it is the window's real answer, not a miss. min/max_cpu_ms and min/max_elapsed_ms are LIFETIME extremes, not windowed; cpu_attribution's ratio is omitted, not invented, when its inputs are missing. window_truncated marks a window floor, not a page cut; effective_start / effective_hours_back give the reach actually served. <> On tier_used=hourly, min/max_cpu_ms and min/max_elapsed_ms are null, as are the columns the rollup does not carry (see precision_note), and parallel_only/min_dop/group_by=host_object keep the read on raw. 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). 'host_object' rolls all of a procedure's statements into one row — use it when dynamic SQL with per-value literals fragments one statement across many hashes, which no top-N-by-hash ranking can surface. Ad-hoc statements have no host object and stay grouped per hash in both modes. distinct_query_hashes reports how many hashes a row rolled up. 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; every filter is applied IN the query before the ranking and the cap, so the page is the top-N of the FILTERED population (filter_applied names the parallelism floor in force, null when none), and an empty page under parallel_only/min_dop is the window's answer rather than a page artefact. 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. max_dop comes from sys.dm_exec_query_stats and is a lifetime-max for the plan's time in cache, so a plan compiled before MAXDOP was lowered keeps reporting the old higher value until it is evicted or recompiled; confirm current parallelism with analyze_query_plan, which reads the actual plan." + McpHelpers.WindowTruncatedDescription + " " + McpToolGuideTopics.CpuTimeExtremesAndAttribution)]
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs
index b185ea004..e1efbb6c0 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs
@@ -166,7 +166,7 @@ appears where snapshots exist with nothing granted. Lite's tool builds the same
}
}
- [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. artifacts_set_aside counts an isolated single-sample Wait Statistics spike excluded from the sums before bucketing (notes explains it); it is not a missing-data count. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
+ [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. artifacts_set_aside counts an isolated single-sample Wait Statistics spike excluded from the sums before bucketing (notes explains it); it is not a missing-data count. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable), and each point carries it as per_second (null where the interval is 0); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
public static Task GetPerfmonTrend(
NpgsqlDataSource postgres,
[Description("The exact counter name, e.g. 'Batch Requests/sec'.")] string counter_name,
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
index 286c63a06..292b68154 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
@@ -266,7 +266,7 @@ export function perfmonPanel(server, ctx) {
const chartSlot = el("div", {}, [loadingStrip()]);
const picker = pickerControl("Counter", names, (name) => drawPerfmonTrend(chartSlot, server, ctx, name));
mount(body, [
- VIZ.table(res.data, {
+ VIZ.table({ ...res.data, counters: perfmonRows(res.data.counters) }, {
rowsKey: "counters",
columns: PERFMON_COLUMNS,
emptyText: "No perfmon counters in the latest snapshot.",
@@ -300,10 +300,7 @@ async function drawPerfmonTrend(slot, server, ctx, counterName) {
renderLineChart({
points: trend.data.trend || [],
xKey: "time",
- series: [
- { key: "value", label: "Value", color: SERIES_COLORS[0] },
- { key: "delta_value", label: "Delta", color: SERIES_COLORS[1] },
- ],
+ ...perfmonTrendLines(trend.data.trend || []),
formatValue: (v) => Number(v).toLocaleString(undefined, { maximumFractionDigits: 2 }),
/* #2802: axis spans the requested window (ctx.hours ending now), not the data's own extent. */
...windowFromHours(ctx.hours),
@@ -311,6 +308,32 @@ async function drawPerfmonTrend(slot, server, ctx, counterName) {
]);
}
+/* A rate counter's stored value is its running total since the counter started, not a rate, so the Perfmon grid
+ shows the per-second figure the server worked out for the row (per_second, a key only a rate row carries) and
+ leaves the running total out: nobody reads it as a number per second. Where no delta was knowable (per_second is
+ null: a first collection, a counter reset or a restart) the stored delta beside it is a stand-in 0, not a count,
+ so that cell is left blank too. A gauge's value is its reading and stays, and so does any other row's. */
+function perfmonRows(counters) {
+ return (counters || []).map((c) =>
+ c && "per_second" in c ? { ...c, value: null, delta_value: c.per_second == null ? null : c.delta_value } : c
+ );
+}
+
+/* The trend chart's lines for the picked counter. A rate counter's points carry per_second, the figure the desktop
+ charts plot for it, and that is the one line: its value only climbs. Every other counter keeps its value and
+ delta lines, so a gauge still plots its reading. */
+function perfmonTrendLines(points) {
+ if (points.some((p) => p && "per_second" in p)) {
+ return { series: [{ key: "per_second", label: "Per second", color: SERIES_COLORS[0] }], unit: "/s" };
+ }
+ return {
+ series: [
+ { key: "value", label: "Value", color: SERIES_COLORS[0] },
+ { key: "delta_value", label: "Delta", color: SERIES_COLORS[1] },
+ ],
+ };
+}
+
/**
* Top Queries by CPU, plus that ONE query's per-collection history — the browser's per-query drill-down (#2520).
*
@@ -3213,6 +3236,7 @@ const JOB_COLUMNS = [
const PERFMON_COLUMNS = [
{ key: "counter_name", label: "Counter" },
{ key: "instance_name", label: "Instance" },
+ { key: "per_second", label: "Per second", format: "num2" },
{ key: "value", label: "Value", format: "num2" },
{ key: "delta_value", label: "Delta", format: "num2" },
];
diff --git a/Lite.Tests/DeltaSeriesShapingTests.cs b/Lite.Tests/DeltaSeriesShapingTests.cs
index 7150d4130..980d4b6a7 100644
--- a/Lite.Tests/DeltaSeriesShapingTests.cs
+++ b/Lite.Tests/DeltaSeriesShapingTests.cs
@@ -280,6 +280,27 @@ public void MaxFinite_IgnoresNaN_AndFallsBackWhenNothingIsFinite()
Assert.Equal(1.0, DeltaSeriesShaping.MaxFinite(new[] { 1.0, double.PositiveInfinity }, fallback: 0));
}
+ /* ---- one row's per-second figure (the latest-snapshot tools) ------------------------------------------ */
+
+ /// A single row is rated by itself: its delta over its stored
+ /// interval, and null wherever the chart would break its line. A lone row has no previous sample, so a NULL
+ /// interval gets no fallback.
+ [Fact]
+ public void PerSecond_IsTheDeltaOverTheStoredInterval_AndNullWhereTheChartBreaksItsLine()
+ {
+ Assert.Equal(0.22, DeltaSeriesShaping.PerSecond(66, 300)!.Value, precision: 10);
+ Assert.Equal(0.0, DeltaSeriesShaping.PerSecond(0, 300)); // idle is a measurement
+ Assert.Null(DeltaSeriesShaping.PerSecond(0, 0)); // the calculator's "no delta knowable"
+ Assert.Null(DeltaSeriesShaping.PerSecond(5, 0)); // the interval decides, not the delta
+ Assert.Null(DeltaSeriesShaping.PerSecond(66, null)); // no interval stored, no previous sample
+ Assert.Null(DeltaSeriesShaping.PerSecond(null, 300)); // no delta stored (a gauge row)
+ Assert.Null(DeltaSeriesShaping.PerSecond(66, -300)); // not a state the calculator writes
+
+ /* The same number the series shaping gives that point. */
+ var series = DeltaSeriesShaping.Shape(new[] { At(0, 0, 0), At(5, 66, 300) }, DeltaBasis.PerSecond);
+ Assert.Equal(series[1], DeltaSeriesShaping.PerSecond(66, 300));
+ }
+
/* ---- the grid cells ----------------------------------------------------------------------------------- */
/// The interval decides, not the delta's value: a 5 beside a 0 interval is as fabricated as a 0
diff --git a/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt b/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt
index a522826c9..6ad94abe4 100644
--- a/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt
+++ b/Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt
@@ -1,5 +1,5 @@
# McpPerfmonTools: tools/list budget for #3898. Ceilings only go down; see McpToolsListBudgetTests. One block per tool, blank line between blocks.
-tool get_perfmon_stats 521
+tool get_perfmon_stats 579
param get_perfmon_stats.counter_name 61
param get_perfmon_stats.instance_name 57
param get_perfmon_stats.server_name 28
diff --git a/Lite.Tests/McpToolsListBudgetTests.cs b/Lite.Tests/McpToolsListBudgetTests.cs
index e2f7d7f97..0987fcaa3 100644
--- a/Lite.Tests/McpToolsListBudgetTests.cs
+++ b/Lite.Tests/McpToolsListBudgetTests.cs
@@ -139,7 +139,9 @@ each gain that same sentence (474 -> 600 and 406 -> 532, +126 bytes apiece), mak
byte-identical to Darling's. Net +181, matching Darling's twin change exactly. */
// #4452: get_scheduler_issues' description grew to stay byte-identical with Darling's twin
// (+95). Constant set to the value McpToolsListBudgetTests itself measured on this tree.
- private const int TotalCeilingBytes = 92_317;
+ // get_perfmon_stats' head names the new per_second field in its rate clause (521 -> 579), byte-identical
+ // with Darling's twin. Constant set to the value McpToolsListBudgetTests itself measured on this tree.
+ private const int TotalCeilingBytes = 92_358;
private const int ConvertedHeadCap = 1_000;
private const int ConvertedParameterCap = 200;
diff --git a/Lite.Tests/PerfmonCounterTypeTests.cs b/Lite.Tests/PerfmonCounterTypeTests.cs
index 869f230a4..6272029dc 100644
--- a/Lite.Tests/PerfmonCounterTypeTests.cs
+++ b/Lite.Tests/PerfmonCounterTypeTests.cs
@@ -167,14 +167,15 @@ public void BothMcpPerfmonTools_DescribeTheKinds_AndSpellThemThroughTheVocabular
foreach (var path in new[] { "Lite/Mcp/McpPerfmonTools.cs", "Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs", "Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs" })
{
var source = Lite.Tests.ParitySource.ReadFile(path);
- /* #3960: get_perfmon_trend on both SKUs now builds its envelope through the shared
- TrendPayloads.PerfmonTrend, which is the one place left calling PerfmonCounterTypes.Word(seriesType)
- for a BUCKETED point — get_perfmon_stats (get_perfmon_stats' own body, in McpPerfmonTools.cs and
- DarlingMcpDataTools.cs) still calls it directly for its un-bucketed latest snapshot. Either shape
+ /* #3960: get_perfmon_trend on both SKUs builds its envelope through the shared TrendPayloads.PerfmonTrend,
+ and get_perfmon_stats builds each latest-snapshot row through the shared TrendPayloads.PerfmonLatestRow;
+ those two are where PerfmonCounterTypes.Word is called for the MCP payloads. Any of the three shapes
proves the file classifies counter_kind through the one vocabulary. */
Assert.True(
- source.Contains("PerfmonCounterTypes.Word(", StringComparison.Ordinal) || source.Contains("TrendPayloads.PerfmonTrend(", StringComparison.Ordinal),
- $"{path}: neither classifies counter_kind directly nor routes through the shared TrendPayloads.PerfmonTrend builder");
+ source.Contains("PerfmonCounterTypes.Word(", StringComparison.Ordinal)
+ || source.Contains("TrendPayloads.PerfmonTrend(", StringComparison.Ordinal)
+ || source.Contains("TrendPayloads.PerfmonLatestRow(", StringComparison.Ordinal),
+ $"{path}: neither classifies counter_kind directly nor routes through a shared TrendPayloads perfmon builder");
Assert.Contains("counter_kind", source, StringComparison.Ordinal);
}
@@ -182,7 +183,12 @@ proves the file classifies counter_kind through the one vocabulary. */
var darlingStats = Lite.Tests.ParitySource.ReadFile("Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs");
var darlingTrend = Lite.Tests.ParitySource.ReadFile("Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs");
- foreach (var phrase in new[] { "'gauge' means value IS the reading", "delta_value is null because a level has no delta", "'rate' means value is a cumulative count", "null counter_kind is a row written before the type was stored" })
+ /* get_perfmon_stats' rows, per_second included, come from ONE builder on both SKUs, so the two cannot
+ publish the rate differently. */
+ Assert.Contains("TrendPayloads.PerfmonLatestRow(", lite, StringComparison.Ordinal);
+ Assert.Contains("TrendPayloads.PerfmonLatestRow(", darlingStats, StringComparison.Ordinal);
+
+ foreach (var phrase in new[] { "'gauge' means value IS the reading", "delta_value is null because a level has no delta", "'rate' means value is a cumulative count", "null counter_kind is a row written before the type was stored", "per_second that change per second (null when unknowable)" })
{
Assert.Contains(phrase, lite, StringComparison.Ordinal);
Assert.Contains(phrase, darlingStats, StringComparison.Ordinal);
@@ -362,6 +368,63 @@ public async Task TheReads_CarryTheStoredType_NullTheGaugesDelta_AndReportNoType
}
}
+ ///
+ /// A rate counter's stored value is its running total, so the latest snapshot also carries the number a reader
+ /// means by the counter: per_second, the row's delta over the seconds since the previous collection
+ /// (66 batches over 300 s is 0.22 a second, never 11,641). The rule is the charts' own
+ /// (), so a row with no stored type is rated by its name,
+ /// a rate whose delta was not knowable (an interval of 0) says null rather than 0, and a gauge or an
+ /// average's numerator has no such key at all. The trend's rate points carry the same figure; its gauge points
+ /// do not.
+ ///
+ [Fact]
+ public async Task ARateCounter_CarriesItsPerSecondFigure_AndNoOtherKindDoes()
+ {
+ var t1 = Truncate(DateTime.UtcNow.AddMinutes(-20));
+ var t2 = t1.AddMinutes(5);
+
+ await SeedAsync(t1, "SQLServer:SQL Statistics", "Batch Requests/sec", "", cntr: 11_000, delta: 0, interval: 0, type: PerfmonCounterTypes.PerfCounterBulkCount);
+ await SeedAsync(t2, "SQLServer:SQL Statistics", "Batch Requests/sec", "", cntr: 11_641, delta: 66, interval: 300, type: PerfmonCounterTypes.PerfCounterBulkCount);
+ await SeedAsync(t2, "SQLServer:SQL Statistics", "SQL Compilations/sec", "", cntr: 4_000, delta: 0, interval: 0, type: PerfmonCounterTypes.PerfCounterBulkCount);
+ await SeedAsync(t1, "SQLServer:Memory Manager", "Total Server Memory (KB)", "", cntr: 8_388_608, delta: null, interval: null, type: PerfmonCounterTypes.PerfCounterLargeRawCount);
+ await SeedAsync(t2, "SQLServer:Memory Manager", "Total Server Memory (KB)", "", cntr: 8_000_000, delta: null, interval: null, type: PerfmonCounterTypes.PerfCounterLargeRawCount);
+ await SeedAsync(t2, "SQLServer:Wait Statistics", "Lock waits", "Average wait time (ms)", cntr: 5_000, delta: 40, interval: 300, type: PerfmonCounterTypes.PerfAverageBulk);
+ await SeedAsync(t2, "SQLServer:General Statistics", "Legacy Transactions/sec", "", cntr: 900, delta: 60, interval: 300, type: null);
+ await SeedAsync(t2, "SQLServer:General Statistics", "Legacy Counter", "", cntr: 120, delta: 20, interval: 300, type: null);
+
+ using (var stats = JsonDocument.Parse(await McpPerfmonTools.GetPerfmonStats(_dataService, _serverManager, "CounterTypeServer")))
+ {
+ var counters = stats.RootElement.GetProperty("counters").EnumerateArray().ToList();
+ JsonElement Row(string name) => counters.Single(c => c.GetProperty("counter_name").GetString() == name);
+
+ var batches = Row("Batch Requests/sec");
+ Assert.Equal(0.22, batches.GetProperty("per_second").GetDouble(), precision: 6);
+ Assert.Equal(11_641, batches.GetProperty("value").GetInt64());
+ Assert.Equal(66, batches.GetProperty("delta_value").GetInt64());
+
+ Assert.Equal(JsonValueKind.Null, Row("SQL Compilations/sec").GetProperty("per_second").ValueKind);
+ Assert.Equal(0.2, Row("Legacy Transactions/sec").GetProperty("per_second").GetDouble(), precision: 6);
+
+ Assert.False(Row("Total Server Memory (KB)").TryGetProperty("per_second", out _));
+ Assert.Equal(8_000_000, Row("Total Server Memory (KB)").GetProperty("value").GetInt64());
+ Assert.False(Row("Lock waits").TryGetProperty("per_second", out _));
+ Assert.False(Row("Legacy Counter").TryGetProperty("per_second", out _));
+ }
+
+ using (var trend = JsonDocument.Parse(await McpPerfmonTools.GetPerfmonTrend(_dataService, _serverManager, "Batch Requests/sec", "CounterTypeServer", hours_back: 1)))
+ {
+ var points = trend.RootElement.GetProperty("trend").EnumerateArray().ToList();
+ Assert.Equal(2, points.Count);
+ Assert.Equal(JsonValueKind.Null, points[0].GetProperty("per_second").ValueKind);
+ Assert.Equal(0.22, points[1].GetProperty("per_second").GetDouble(), precision: 6);
+ }
+
+ using (var trend = JsonDocument.Parse(await McpPerfmonTools.GetPerfmonTrend(_dataService, _serverManager, "Total Server Memory (KB)", "CounterTypeServer", hours_back: 1)))
+ {
+ Assert.All(trend.RootElement.GetProperty("trend").EnumerateArray(), p => Assert.False(p.TryGetProperty("per_second", out _)));
+ }
+ }
+
///
/// The upgrade itself, on a real DuckDB file: a database initialized at the current schema, its
/// cntr_type dropped and its version stamped back to 61 — the shape every existing Lite database has
diff --git a/Lite/Mcp/McpPerfmonTools.cs b/Lite/Mcp/McpPerfmonTools.cs
index 56bfa0276..b1f349b5a 100644
--- a/Lite/Mcp/McpPerfmonTools.cs
+++ b/Lite/Mcp/McpPerfmonTools.cs
@@ -10,7 +10,7 @@ namespace PerformanceMonitorLite.Mcp;
[McpServerToolType]
public sealed class McpPerfmonTools
{
- [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values (batch requests/sec, compilations/sec, deadlocks/sec, and more). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected; use get_perfmon_trend for history. counter_kind: gauge = value IS the reading, delta_value null; rate = value is cumulative, delta_value its per-interval change; other = a non-rate per-interval change; null counter_kind predates the column, classify by name (ends in /sec = rate). <> Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count and delta_value is its change over the last collection interval (get_perfmon_trend carries the sample_interval_seconds to divide it by for a per-second figure), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate).")]
+ [McpServerTool(Name = "get_perfmon_stats"), Description("Gets the latest SQL Server performance counter values (batch requests/sec, compilations/sec, deadlocks/sec, and more). LATEST IS A TIME: the newest snapshot, not a window; captured_at is when it was collected; use get_perfmon_trend for history. counter_kind: gauge = value IS the reading, delta_value null; rate = value is cumulative, delta_value its per-interval change, per_second that change per second (null when unknowable); other = a non-rate per-interval change; null counter_kind predates the column, classify by name (ends in /sec = rate). <> Gets the latest SQL Server performance counter values: batch requests/sec, compilations/sec, deadlocks/sec, and more. Provides throughput context to distinguish a busy server from a sick one. Use counter_name or instance_name to filter results. LATEST IS A TIME: this reads the newest counter snapshot, not a window, and captured_at is the instant it was collected; use get_perfmon_trend for a counter over time. Each row carries counter_kind from the stored cntr_type: 'gauge' means value IS the reading (a level such as Total Server Memory (KB); delta_value is null because a level has no delta), 'rate' means value is a cumulative count (a running total, not a rate), delta_value is its change over the last collection interval, and per_second is that change divided by the interval's seconds: the counter's rate, the figure to report for it, and null when no delta was knowable (a first collection, a counter reset or a restart), 'other' means an average/fraction numerator whose delta_value is a per-interval change and not a rate; null counter_kind is a row written before the type was stored — classify it by name (a counter whose name ends in /sec is a rate, and carries per_second).")]
public static async Task GetPerfmonStats(
LocalDataService dataService,
ServerManager serverManager,
@@ -36,18 +36,13 @@ public static async Task GetPerfmonStats(
if (!string.IsNullOrEmpty(instance_name))
filtered = filtered.Where(r => r.InstanceName != null && r.InstanceName.Contains(instance_name, StringComparison.OrdinalIgnoreCase));
- /* counter_kind is the stored type's three-way reading (v62, #3653 A7) through the one shared
- vocabulary; delta_value is null on a gauge because the collector writes none — the reading is
- value — and null on nothing else. Twin of Darling's DarlingMcpDataTools. */
- var result = filtered.Select(r => new
- {
- counter_name = r.CounterName,
- instance_name = r.InstanceName,
- value = r.Value,
- delta_value = r.DeltaValue,
- cntr_type = r.CntrType,
- counter_kind = PerfmonCounterTypes.Word(r.CntrType)
- });
+ /* One row per counter, built by the shared TrendPayloads.PerfmonLatestRow that Darling's
+ DarlingMcpDataTools calls too: counter_kind is the stored type's three-way reading (v62, #3653 A7),
+ delta_value is null on a gauge because the collector writes none, and a rate row adds per_second,
+ its delta over the stored interval, so the running total in value is never the only number a
+ reader gets. */
+ var result = filtered.Select(r => TrendPayloads.PerfmonLatestRow(
+ r.CounterName, r.InstanceName, r.Value, r.DeltaValue, r.SampleIntervalSeconds, r.CntrType));
return JsonSerializer.Serialize(new
{
@@ -63,7 +58,7 @@ public static async Task GetPerfmonStats(
}
}
- [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
+ [McpServerTool(Name = "get_perfmon_trend"), Description("Gets one performance counter over time in buckets, ending at as_of. counter_kind says the unit: gauge - value is the bucket average, delta_value and sample_interval_seconds null (no delta for a level); rate - per-second is delta_value divided by sample_interval_seconds, never delta_value alone or when the interval is 0; other - delta_value is a non-rate change; null - classify by name (ends in /sec = rate). No points never returns empty: not_collected covers a gated engine, Page Life Expectancy, or an unknown counter name; unavailable: no counter at all collected in the window. <> Gets one performance counter over time in time buckets. Use get_perfmon_stats first to see available counter names. counter_kind (from the stored cntr_type) says what a point's number is: 'gauge' — value is the bucket's average reading and peak_value its highest, delta_value and sample_interval_seconds are null because a level has no delta; 'rate' — the per-second figure is delta_value divided by sample_interval_seconds, never delta_value alone, and never where sample_interval_seconds is 0 (no delta was knowable), and each point carries it as per_second (null where the interval is 0); 'other' — delta_value is the change of an average/fraction numerator, not a rate and not a level; null — the rows predate the stored type or the instances mix types, so classify by name (a name ending in /sec is a rate)." + BaselineDiscontinuities.DescriptionSentence)]
public static async Task GetPerfmonTrend(
LocalDataService dataService,
ServerManager serverManager,
diff --git a/Lite/Services/LocalDataService.Perfmon.cs b/Lite/Services/LocalDataService.Perfmon.cs
index 2ce4bcd9a..452a41c6d 100644
--- a/Lite/Services/LocalDataService.Perfmon.cs
+++ b/Lite/Services/LocalDataService.Perfmon.cs
@@ -33,7 +33,8 @@ public async Task> GetLatestPerfmonStatsAsync(int serverId)
cntr_value,
delta_cntr_value,
collection_time,
- cntr_type
+ cntr_type,
+ sample_interval_seconds
FROM v_perfmon_stats
WHERE server_id = $1
AND collection_time = (SELECT MAX(collection_time) FROM v_perfmon_stats WHERE server_id = $1)
@@ -53,7 +54,8 @@ FROM v_perfmon_stats
/* NULL stays NULL: a gauge row stores no delta (v62), and a 0 here would be #3642's fabricated zero. */
DeltaValue = reader.IsDBNull(3) ? null : reader.GetInt64(3),
CollectionTime = reader.GetDateTime(4),
- CntrType = reader.IsDBNull(5) ? null : reader.GetInt32(5)
+ CntrType = reader.IsDBNull(5) ? null : reader.GetInt32(5),
+ SampleIntervalSeconds = reader.IsDBNull(6) ? null : reader.GetInt32(6)
});
}
@@ -371,6 +373,10 @@ public class PerfmonRow
/// The DMV's cntr_type as stored (v62, #3653 A7): the id every reader classifies by through
/// PerfmonCounterTypes; null on a row written before the rung.
public int? CntrType { get; set; }
+
+ /// The seconds covers under the three-state rule (0 = no delta knowable, null
+ /// on a gauge): the denominator of a rate row's per-second figure.
+ public int? SampleIntervalSeconds { get; set; }
}
public class PerfmonTrendPoint
diff --git a/PerformanceMonitor.Common/DeltaSeriesShaping.cs b/PerformanceMonitor.Common/DeltaSeriesShaping.cs
index 51fe0bcb5..7543d5b1c 100644
--- a/PerformanceMonitor.Common/DeltaSeriesShaping.cs
+++ b/PerformanceMonitor.Common/DeltaSeriesShaping.cs
@@ -258,6 +258,19 @@ than flip the sign of a rate. */
return ys;
}
+ ///
+ /// One row's per-second figure under 's rule, for a
+ /// surface that shows a single collection rather than a series (the latest-snapshot perfmon tools): the delta
+ /// over the stored interval, and null wherever plots NaN — an interval of 0 (no
+ /// delta was knowable), a missing delta, or a NULL interval, which a lone row has no previous sample to fall
+ /// back on. Computed by itself, so the snapshot and the charts cannot disagree.
+ ///
+ public static double? PerSecond(long? delta, long? intervalSeconds)
+ {
+ var y = Shape([new DeltaSample(default, delta, intervalSeconds)], DeltaBasis.PerSecond)[0];
+ return double.IsNaN(y) ? null : y;
+ }
+
/// The legend entry: the counter's own name for a rate (its name already says /sec) and for
/// a gauge (its name is its unit: Total Server Memory (KB)), the name plus
/// for a per-interval delta.
diff --git a/PerformanceMonitor.Common/Mcp/TrendPayloads.cs b/PerformanceMonitor.Common/Mcp/TrendPayloads.cs
index 731618a78..05c5f5334 100644
--- a/PerformanceMonitor.Common/Mcp/TrendPayloads.cs
+++ b/PerformanceMonitor.Common/Mcp/TrendPayloads.cs
@@ -79,7 +79,8 @@ internal sealed record PerfmonBucketPoint(
///
/// The wire shapes of the bucketed trends both SKUs serve with the same fields — get_file_io_trend and
-/// get_lock_wait_trend (#3897), and the wait, CPU, tempdb, memory and perfmon trends (#3960). Built here,
+/// get_lock_wait_trend (#3897), the wait, CPU, tempdb, memory and perfmon trends (#3960), and one row of
+/// get_perfmon_stats' latest snapshot, which states a rate the way the perfmon trend does. Built here,
/// once, from the records above, so Lite and Darling cannot publish different keys, orders or sentences for the
/// same read: the parity their tool bodies used to keep by copying is now kept by construction.
///
@@ -448,7 +449,9 @@ public static string MemoryTrend(
/// gauge always carried; anything cumulative publishes the bucket's LAST reading as value and its deltas
/// summed over the seconds they accrued as delta_value / sample_interval_seconds, so the
/// per-second figure is still delta over interval and a bucket whose every collection was unknowable still says
- /// so with an interval of 0; a RATE adds peak_per_second, its busiest single collection.
+ /// so with an interval of 0; a RATE adds per_second, the figure both desktop charts plot for the same
+ /// points ( over each bucket's delta and seconds, null where the chart
+ /// breaks its line), and peak_per_second, its busiest single collection.
///
public static string PerfmonTrend(
string serverName, string counterName, int hoursBack, IReadOnlyList points,
@@ -456,6 +459,13 @@ public static string PerfmonTrend(
{
var seriesType = points.Select(p => p.CntrType).LastOrDefault(t => t.HasValue);
var basis = DeltaSeriesShaping.BasisFor(counterName, seriesType);
+ var perSecond = basis == DeltaBasis.PerSecond
+ ? DeltaSeriesShaping.Shape(points.Select(p =>
+ {
+ var (delta, seconds) = PerfmonDeltas(p);
+ return new DeltaSample(p.BucketStart, delta, seconds);
+ }).ToList(), basis)
+ : null;
/* -1 (the default) means the caller has no separate total — points that were fully set aside never
got dropped before reaching here, so summing the published points' own counts is complete. A
caller (get_perfmon_trend) that CAN drop a fully-set-aside bucket passes its own pre-drop total,
@@ -477,7 +487,7 @@ since summing the survivors would then undercount. */
["aggregate_note"] = basis == DeltaBasis.Level
? TrendBuckets.LevelNote(bucketMinutes, requested, autoBudget)
: TrendBuckets.AggregateNote(bucketMinutes, requested, autoBudget),
- ["trend"] = points.Select(p => PerfmonPoint(p, basis)),
+ ["trend"] = points.Select((p, i) => PerfmonPoint(p, basis, perSecond?[i])),
["discontinuities"] = discontinuities,
["artifacts_set_aside"] = artifactsSetAside,
};
@@ -491,9 +501,10 @@ since summing the survivors would then undercount. */
return JsonSerializer.Serialize(envelope, McpHelpers.JsonOptions);
}
- /// One perfmon point in its counter kind's shape: the peak key only where the kind has one, so a caller
- /// never reads a null peak as a measured absence.
- private static Dictionary PerfmonPoint(PerfmonBucketPoint p, DeltaBasis basis)
+ /// One perfmon point in its counter kind's shape: the per-second and peak keys only where the kind has
+ /// them, so a caller never reads a null as a measured absence. is the point's value
+ /// in the series returned, NaN where no delta was knowable.
+ private static Dictionary PerfmonPoint(PerfmonBucketPoint p, DeltaBasis basis, double? perSecond)
{
if (basis == DeltaBasis.Level)
{
@@ -518,12 +529,46 @@ since summing the survivors would then undercount. */
if (basis == DeltaBasis.PerSecond)
{
+ point["per_second"] = perSecond is { } rate && !double.IsNaN(rate) ? Math.Round(rate, 4) : null;
point["peak_per_second"] = p.PeakPerSecond is { } peak ? Math.Round(peak, 4) : null;
}
return point;
}
+ ///
+ /// One row of get_perfmon_stats' latest snapshot, both SKUs. value is the stored counter (a rate
+ /// counter's running total, a gauge's reading), delta_value its change over the last collection interval
+ /// (null on a gauge, which stores none), and counter_kind the stored type's word
+ /// (). A row the desktop charts plot per second
+ /// (: a rate type, or a row with no stored type whose
+ /// name says /sec) adds per_second: its delta over the seconds since the previous collection
+ /// (), null when no delta was knowable. No other row has the key, as
+ /// with 's rate-only keys, so a reader never takes a gauge's null for an unknown rate.
+ ///
+ public static Dictionary PerfmonLatestRow(
+ string counterName, string instanceName, long value, long? deltaValue, long? sampleIntervalSeconds, int? cntrType)
+ {
+ var row = new Dictionary
+ {
+ ["counter_name"] = counterName,
+ ["instance_name"] = instanceName,
+ ["value"] = value,
+ ["delta_value"] = deltaValue,
+ ["cntr_type"] = cntrType,
+ ["counter_kind"] = PerfmonCounterTypes.Word(cntrType),
+ };
+
+ if (DeltaSeriesShaping.BasisFor(counterName, cntrType) == DeltaBasis.PerSecond)
+ {
+ row["per_second"] = DeltaSeriesShaping.PerSecond(deltaValue, sampleIntervalSeconds) is { } rate
+ ? Math.Round(rate, 4)
+ : null;
+ }
+
+ return row;
+ }
+
/// A cumulative counter's delta and the seconds it accrued over, one class at a time: the rated
/// collections' where any exist; else the unknowable ones' with an interval of 0, the marker a caller must not
/// divide by; else the unrecorded (pre-V127) ones' with no interval at all. Each delta is the class's own sum,
From eb757ca43145ca3e8063298e33c0ccba8415abfb Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 30 Sep 2026 19:54:30 -0400
Subject: [PATCH 2/7] One per-second division in DeltaSeriesShaping serves the
charts and the payloads; the rate census sweeps a bare per_second key
---
.../MeasurementContractCensusTests.cs | 12 ++++++----
Lite.Tests/MeasurementContractCensusTests.cs | 14 +++++++-----
.../DeltaSeriesShaping.cs | 21 +++++++++---------
.../Mcp/TrendPayloads.cs | 22 ++++++-------------
4 files changed, 34 insertions(+), 35 deletions(-)
diff --git a/Darling/Darling.Tests/MeasurementContractCensusTests.cs b/Darling/Darling.Tests/MeasurementContractCensusTests.cs
index 83725078b..b37c73228 100644
--- a/Darling/Darling.Tests/MeasurementContractCensusTests.cs
+++ b/Darling/Darling.Tests/MeasurementContractCensusTests.cs
@@ -387,16 +387,16 @@ public void EveryPerSecondAlias_IsAQuotient()
/* ---------------- rule 6, the C# half (the Lite half's regexes, verbatim) ---------------- */
private static readonly Regex IdentifierPayloadKey = new(
- @"(?\w+_per_sec(?:ond)?)\s*=(?![=>])", RegexOptions.Compiled);
+ @"(?(?:\w+_)?per_sec(?:ond)?)\s*=(?![=>])", RegexOptions.Compiled);
private static readonly Regex QuotedPayloadKey = new(
- @"\[\s*""(?\w+_per_sec(?:ond)?)""\s*\]\s*=(?![=>])", RegexOptions.Compiled);
+ @"\[\s*""(?(?:\w+_)?per_sec(?:ond)?)""\s*\]\s*=(?![=>])", RegexOptions.Compiled);
private static readonly Regex AddedPayloadKey = new(
- @"\.(?:Try)?Add\(\s*""(?\w+_per_sec(?:ond)?)""\s*,", RegexOptions.Compiled);
+ @"\.(?:Try)?Add\(\s*""(?(?:\w+_)?per_sec(?:ond)?)""\s*,", RegexOptions.Compiled);
private static readonly Regex PerSecondKeyConstant = new(
- @"\bconst\s+string\s+(?\w+)\s*=\s*""(?\w+_per_sec(?:ond)?)""\s*;", RegexOptions.Compiled);
+ @"\bconst\s+string\s+(?\w+)\s*=\s*""(?(?:\w+_)?per_sec(?:ond)?)""\s*;", RegexOptions.Compiled);
private static readonly Regex ConstantIndexerKey = new(
@"\[\s*(?:\w+\s*\.\s*)*(?\w+)\s*\]\s*=(?![=>])", RegexOptions.Compiled);
@@ -552,6 +552,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
["assumed_per_sec"] = row.DeltaValue / 60.0,
[Keys.ConstPerSecKey] = ops / observedSeconds,
["unknown_per_sec"] = something,
+ ["per_second"] = PerSecond(row.DeltaValue, row.SampleIntervalSeconds),
};
stamped.Add("added_per_sec", total / elapsed.TotalSeconds);
var page = new
@@ -564,6 +565,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
halved_per_second = total / count,
renamed_per_second = p.Value,
mapped_per_second = "a_table",
+ per_sec = row.DeltaValue,
compared = ms_per_sec == 0,
};
""";
@@ -591,6 +593,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
("assumed_per_sec", "OFFENDER"),
("constant_per_sec", Quotient),
("unknown_per_sec", UnderAnotherName),
+ ("per_second", RateHelper),
("added_per_sec", Quotient),
("divided_per_second", Quotient),
("helped_per_second", RateHelper),
@@ -600,6 +603,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
("halved_per_second", "OFFENDER"),
("renamed_per_second", UnderAnotherName),
("mapped_per_second", MapEntry),
+ ("per_sec", "OFFENDER"),
},
verdicts);
diff --git a/Lite.Tests/MeasurementContractCensusTests.cs b/Lite.Tests/MeasurementContractCensusTests.cs
index e50532332..2b5a7bf17 100644
--- a/Lite.Tests/MeasurementContractCensusTests.cs
+++ b/Lite.Tests/MeasurementContractCensusTests.cs
@@ -802,24 +802,24 @@ the real one (dozens of definitions) and perfmon's delta column is where the /se
/// inside a literal (WHERE ms_per_sec = 0) is blank where this looks. Not ==, not
/// =>.
private static readonly Regex IdentifierPayloadKey = new(
- @"(?\w+_per_sec(?:ond)?)\s*=(?![=>])", RegexOptions.Compiled);
+ @"(?(?:\w+_)?per_sec(?:ond)?)\s*=(?![=>])", RegexOptions.Compiled);
/// A payload key spelled as a string and given a value through an indexer:
/// ["current_ms_per_sec"] = peakRate. Read off the comments-blanked text, where the literal is
/// still legible and the brackets and the = are code.
private static readonly Regex QuotedPayloadKey = new(
- @"\[\s*""(?\w+_per_sec(?:ond)?)""\s*\]\s*=(?![=>])", RegexOptions.Compiled);
+ @"\[\s*""(?(?:\w+_)?per_sec(?:ond)?)""\s*\]\s*=(?![=>])", RegexOptions.Compiled);
/// The same key given a value through Add("…", value) / TryAdd. No product site writes
/// this shape today; it is swept so the day one does it is a site and not a gap.
private static readonly Regex AddedPayloadKey = new(
- @"\.(?:Try)?Add\(\s*""(?\w+_per_sec(?:ond)?)""\s*,", RegexOptions.Compiled);
+ @"\.(?:Try)?Add\(\s*""(?(?:\w+_)?per_sec(?:ond)?)""\s*,", RegexOptions.Compiled);
/// A const string whose VALUE is a per-second name (IoOpsPerSecKey = "ops_per_sec"), so a
/// key written through the constant ([PgTargetScorer.IoOpsPerSecKey] = …) is swept under the name it
/// really writes.
private static readonly Regex PerSecondKeyConstant = new(
- @"\bconst\s+string\s+(?\w+)\s*=\s*""(?\w+_per_sec(?:ond)?)""\s*;", RegexOptions.Compiled);
+ @"\bconst\s+string\s+(?\w+)\s*=\s*""(?(?:\w+_)?per_sec(?:ond)?)""\s*;", RegexOptions.Compiled);
/// An indexer keyed by an identifier ([X.Y] = …), a candidate constant-keyed site; it counts only
/// when the last identifier is one of 's names.
@@ -1005,6 +1005,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
["assumed_per_sec"] = row.DeltaValue / 60.0,
[Keys.ConstPerSecKey] = ops / observedSeconds,
["unknown_per_sec"] = something,
+ ["per_second"] = PerSecond(row.DeltaValue, row.SampleIntervalSeconds),
};
stamped.Add("added_per_sec", total / elapsed.TotalSeconds);
var page = new
@@ -1017,6 +1018,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
halved_per_second = total / count,
renamed_per_second = p.Value,
mapped_per_second = "a_table",
+ per_sec = row.DeltaValue,
compared = ms_per_sec == 0,
};
""";
@@ -1043,7 +1045,8 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
("planted_per_sec", "OFFENDER"), /* THE planted passthrough: a stored delta under a rate name */
("assumed_per_sec", "OFFENDER"), /* a delta over a cadence literal */
("constant_per_sec", Quotient), /* through a const, divided by observedSeconds */
- ("unknown_per_sec", UnderAnotherName), /* a local nothing explains, under a name no SQL divides for */
+ ("unknown_per_sec", UnderAnotherName),
+ ("per_second", RateHelper), /* a bare key, no prefix: swept all the same */ /* a local nothing explains, under a name no SQL divides for */
("added_per_sec", Quotient), /* Add(key, total / elapsed.TotalSeconds) */
("divided_per_second", Quotient),
("helped_per_second", RateHelper),
@@ -1053,6 +1056,7 @@ internal static class Keys { public const string ConstPerSecKey = "constant_per_
("halved_per_second", "OFFENDER"), /* divides by a count, not a span */
("renamed_per_second", UnderAnotherName), /* p.Value: not the field of its own name */
("mapped_per_second", MapEntry), /* a name mapped to a name — outside the rule */
+ ("per_sec", "OFFENDER"), /* a bare key over a stored delta */
},
verdicts);
diff --git a/PerformanceMonitor.Common/DeltaSeriesShaping.cs b/PerformanceMonitor.Common/DeltaSeriesShaping.cs
index 7543d5b1c..2f332ccef 100644
--- a/PerformanceMonitor.Common/DeltaSeriesShaping.cs
+++ b/PerformanceMonitor.Common/DeltaSeriesShaping.cs
@@ -252,24 +252,23 @@ than flip the sign of a rate. */
seconds = (s.Time - samples[i - 1].Time).TotalSeconds;
}
- ys[i] = seconds > 0 ? delta / seconds : double.NaN;
+ ys[i] = PerSecond(delta, seconds) ?? double.NaN;
}
return ys;
}
///
- /// One row's per-second figure under 's rule, for a
- /// surface that shows a single collection rather than a series (the latest-snapshot perfmon tools): the delta
- /// over the stored interval, and null wherever plots NaN — an interval of 0 (no
- /// delta was knowable), a missing delta, or a NULL interval, which a lone row has no previous sample to fall
- /// back on. Computed by itself, so the snapshot and the charts cannot disagree.
+ /// The one per-second division: a delta over the seconds it covers when that span is positive, else
+ /// null. rates every point through it once it has
+ /// chosen the span (the stored interval, or for a row that never stored one the spacing to the previous sample),
+ /// and a surface that shows one collection rather than a series (the latest-snapshot perfmon tools, a bucketed
+ /// trend point) hands it the stored interval directly: an interval of 0 (no delta was knowable), a NULL one (a
+ /// lone row has no previous sample to fall back on) or a missing delta gives null, where a chart breaks
+ /// its line. One division, so a snapshot and a chart cannot disagree on a rate.
///
- public static double? PerSecond(long? delta, long? intervalSeconds)
- {
- var y = Shape([new DeltaSample(default, delta, intervalSeconds)], DeltaBasis.PerSecond)[0];
- return double.IsNaN(y) ? null : y;
- }
+ public static double? PerSecond(long? delta, double? intervalSeconds) =>
+ delta is long d && intervalSeconds is > 0 ? d / intervalSeconds.Value : null;
/// The legend entry: the counter's own name for a rate (its name already says /sec) and for
/// a gauge (its name is its unit: Total Server Memory (KB)), the name plus
diff --git a/PerformanceMonitor.Common/Mcp/TrendPayloads.cs b/PerformanceMonitor.Common/Mcp/TrendPayloads.cs
index 05c5f5334..a5d800227 100644
--- a/PerformanceMonitor.Common/Mcp/TrendPayloads.cs
+++ b/PerformanceMonitor.Common/Mcp/TrendPayloads.cs
@@ -449,9 +449,9 @@ public static string MemoryTrend(
/// gauge always carried; anything cumulative publishes the bucket's LAST reading as value and its deltas
/// summed over the seconds they accrued as delta_value / sample_interval_seconds, so the
/// per-second figure is still delta over interval and a bucket whose every collection was unknowable still says
- /// so with an interval of 0; a RATE adds per_second, the figure both desktop charts plot for the same
- /// points ( over each bucket's delta and seconds, null where the chart
- /// breaks its line), and peak_per_second, its busiest single collection.
+ /// so with an interval of 0; a RATE adds per_second, that delta over those seconds through the division
+ /// both desktop charts rate a point with (, null where the interval is
+ /// 0 or missing), and peak_per_second, its busiest single collection.
///
public static string PerfmonTrend(
string serverName, string counterName, int hoursBack, IReadOnlyList points,
@@ -459,13 +459,6 @@ public static string PerfmonTrend(
{
var seriesType = points.Select(p => p.CntrType).LastOrDefault(t => t.HasValue);
var basis = DeltaSeriesShaping.BasisFor(counterName, seriesType);
- var perSecond = basis == DeltaBasis.PerSecond
- ? DeltaSeriesShaping.Shape(points.Select(p =>
- {
- var (delta, seconds) = PerfmonDeltas(p);
- return new DeltaSample(p.BucketStart, delta, seconds);
- }).ToList(), basis)
- : null;
/* -1 (the default) means the caller has no separate total — points that were fully set aside never
got dropped before reaching here, so summing the published points' own counts is complete. A
caller (get_perfmon_trend) that CAN drop a fully-set-aside bucket passes its own pre-drop total,
@@ -487,7 +480,7 @@ since summing the survivors would then undercount. */
["aggregate_note"] = basis == DeltaBasis.Level
? TrendBuckets.LevelNote(bucketMinutes, requested, autoBudget)
: TrendBuckets.AggregateNote(bucketMinutes, requested, autoBudget),
- ["trend"] = points.Select((p, i) => PerfmonPoint(p, basis, perSecond?[i])),
+ ["trend"] = points.Select(p => PerfmonPoint(p, basis)),
["discontinuities"] = discontinuities,
["artifacts_set_aside"] = artifactsSetAside,
};
@@ -502,9 +495,8 @@ since summing the survivors would then undercount. */
}
/// One perfmon point in its counter kind's shape: the per-second and peak keys only where the kind has
- /// them, so a caller never reads a null as a measured absence. is the point's value
- /// in the series returned, NaN where no delta was knowable.
- private static Dictionary PerfmonPoint(PerfmonBucketPoint p, DeltaBasis basis, double? perSecond)
+ /// them, so a caller never reads a null as a measured absence.
+ private static Dictionary PerfmonPoint(PerfmonBucketPoint p, DeltaBasis basis)
{
if (basis == DeltaBasis.Level)
{
@@ -529,7 +521,7 @@ since summing the survivors would then undercount. */
if (basis == DeltaBasis.PerSecond)
{
- point["per_second"] = perSecond is { } rate && !double.IsNaN(rate) ? Math.Round(rate, 4) : null;
+ point["per_second"] = DeltaSeriesShaping.PerSecond(delta, seconds) is { } rate ? Math.Round(rate, 4) : null;
point["peak_per_second"] = p.PeakPerSecond is { } peak ? Math.Round(peak, 4) : null;
}
From 54796589747b4304b54b720341444bd07a863fb0 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 30 Sep 2026 21:06:36 -0400
Subject: [PATCH 3/7] Perfmon grid keeps a rate row's running total under its
own header; small rates never read as 0
The web Perfmon Counters grid dropped a rate row's stored value to show its per-second figure. For a counter that
seldom fires that removed the only count there was: Number of Deadlocks/sec at 37 showed no total, and its rate
read 0.00. A rate row now keeps its running total under a Total since start column, whether or not a rate is known.
Only the stand-in 0 delta of an unknowable interval is blanked. Gauges and every other row keep their numbers.
A real low rate also printed as 0. One deadlock in a 300 s collection is 0.0033 a second, but the trend chart
formatted to two decimals, so every axis label read 0 and the tooltip read 0. A rate now prints through fmtRate
(two decimals from 1 up, two significant digits below it) on the chart axis, the tooltip and the grid's Per second
cell. A true 0 still reads 0, and a gauge's chart prints as it did.
get_perfmon_trend and get_perfmon_stats rounded per_second to 4 decimals, so one count in a day-wide bucket
(86,400 s) published as a rate of 0. Both apps build their payloads through TrendPayloads, which now rounds a rate
to 4 decimals, or to two significant digits where 4 decimals would keep fewer.
The web test harness now runs the shipped charts.js, so the pins read the real axis labels and tooltips.
---
.../PerfmonPerSecondPayloadTests.cs | 39 ++++++
.../WebPerfmonPerSecondBehaviourTests.cs | 75 +++++++++--
Darling/Darling.Tests/web-perfmon-harness.mjs | 125 +++++++++++++++---
.../wwwroot/js/editor.js | 2 +-
.../wwwroot/js/pages/server-tabs.js | 30 +++--
.../wwwroot/js/panels.js | 2 +-
.../wwwroot/js/util.js | 12 ++
Lite.Tests/MeasurementContractCensusTests.cs | 4 +-
Lite.Tests/PerfmonPerSecondRoundingTests.cs | 110 +++++++++++++++
.../Mcp/TrendPayloads.cs | 33 ++++-
10 files changed, 379 insertions(+), 53 deletions(-)
create mode 100644 Lite.Tests/PerfmonPerSecondRoundingTests.cs
diff --git a/Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs b/Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs
index 8bc04089e..51993caad 100644
--- a/Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs
+++ b/Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs
@@ -6,6 +6,7 @@
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/
+using System;
using System.Linq;
using System.Text.Json;
using PerformanceMonitor.Common;
@@ -78,4 +79,42 @@ public void ARowWithNoStoredType_IsRatedByItsName()
Assert.Equal(0.2, Payload("Legacy Transactions/sec", 900, 60, null, 300).GetProperty("per_second").GetDouble(), precision: 10);
Assert.False(Payload("Legacy Counter", 120, 20, null, 300).TryGetProperty("per_second", out _));
}
+
+ /// A counter that seldom fires keeps its rate and its running total side by side. One deadlock in 300 s is
+ /// 0.0033 a second; one in the longest interval the calculator rates (3,600 s) keeps two significant digits, where
+ /// four decimals made it 0.0003. The rule itself, TrendPayloads.RoundRate, is pinned value by value in
+ /// Lite.Tests' PerfmonPerSecondRoundingTests.
+ [Fact]
+ public void ARateThatSeldomFires_KeepsItsRate_BesideItsTotal()
+ {
+ var fiveMinutes = Payload("Number of Deadlocks/sec", 37, 1, PerfmonCounterTypes.PerfCounterBulkCount, 300);
+ Assert.Equal(0.0033, fiveMinutes.GetProperty("per_second").GetDouble(), precision: 12);
+ Assert.Equal(37, fiveMinutes.GetProperty("value").GetInt64());
+
+ var anHour = Payload("Number of Deadlocks/sec", 37, 1, PerfmonCounterTypes.PerfCounterBulkCount, 3600);
+ Assert.Equal(0.00028, anHour.GetProperty("per_second").GetDouble(), precision: 12);
+ Assert.Equal(37, anHour.GetProperty("value").GetInt64());
+ }
+
+ /// Darling's get_perfmon_trend builds its points through the shared TrendPayloads.PerfmonTrend.
+ /// The largest bucket a caller can ask for is a day, 86,400 s, and one deadlock in it is a rate that four decimals
+ /// erase. It publishes as the rate it is, and the busiest collection is rounded the same way.
+ [Fact]
+ public void ADayWideTrendBucket_WithOneCount_PublishesItsRate_NotZero()
+ {
+ var seconds = TrendBuckets.MaxBucketMinutes * 60L;
+ var bucket = new PerfmonBucketPoint(
+ new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Unspecified), AvgValue: 0, MaxValue: 0, LastValue: 37,
+ RatedDelta: 1, RatedSeconds: seconds, UnknowableCollections: 0, UnknowableDelta: null, UnrecordedDelta: null,
+ PeakPerSecond: 1.0 / 3600, CntrType: PerfmonCounterTypes.PerfCounterBulkCount);
+
+ using var doc = JsonDocument.Parse(TrendPayloads.PerfmonTrend(
+ "SRV1", "Number of Deadlocks/sec", 168, new[] { bucket }, TrendBuckets.MaxBucketMinutes, requested: true,
+ autoBudget: TrendBuckets.McpPointBudget, discontinuities: Array.Empty