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())); + var point = doc.RootElement.GetProperty("trend")[0]; + + Assert.Equal(0.000012, point.GetProperty("per_second").GetDouble(), precision: 12); + Assert.Equal(0.00028, point.GetProperty("peak_per_second").GetDouble(), precision: 12); + Assert.Equal(1, point.GetProperty("delta_value").GetInt64()); + } } diff --git a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs index cec3ea05e..ab4ecc02e 100644 --- a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs +++ b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs @@ -16,11 +16,12 @@ 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. +/// What the web Perfmon Counters panel draws, from the shipped server-tabs.js and charts.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. +/// second), so the grid shows the row's per_second in a Per second column and the running total under a +/// Total since start header, and the trend chart draws the rate's per_second line, its axis labels and its +/// tooltip printed so that a real rate never reads as 0. 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 @@ -67,23 +68,40 @@ private static bool TryRun(string scenario, out JsonElement result) private static string[] Row(JsonElement result, string counter) => result.GetProperty("rows").EnumerateArray().Select(Strings).Single(cells => cells[0] == counter); + /// The rows a chart's tooltip showed with the pointer at its left or right edge, as [label, value] pairs. + private static string[][] Tooltip(JsonElement chart, string edge) => + chart.GetProperty("tooltips").GetProperty(edge).EnumerateArray().Select(Strings).ToArray(); + + /// The rate under Per second, and the running total under a header that says it is a total: Value is a + /// reading, and a total under it would read as a rate beside the delta. [Fact] - public void TheGrid_ShowsARateCounterPerSecond_AndLeavesItsRunningTotalOut() + public void TheGrid_ShowsARateCounterPerSecond_AndItsRunningTotalUnderATotalHeader() { 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")); + Assert.Equal(new[] { "Counter", "Instance", "Per second", "Total since start", "Value", "Delta" }, Strings(r.GetProperty("headers"))); + Assert.Equal(new[] { "Batch Requests/sec", "—", "0.22", "11,641", "—", "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. + /// and the 0 stored beside its interval of 0 is a stand-in, not a count: those two cells are blank. Its running + /// total was measured all the same, so it stays. [Fact] - public void ARateRowWithNoKnowableDelta_ShowsNoNumber() + public void ARateRowWithNoKnowableDelta_KeepsItsTotal_AndShowsNoRateAndNoDelta() { if (!TryRun("mixed", out var r)) return; - Assert.Equal(new[] { "SQL Compilations/sec", "—", "—", "—", "—" }, Row(r, "SQL Compilations/sec")); + Assert.Equal(new[] { "SQL Compilations/sec", "—", "—", "4,000", "—", "—" }, Row(r, "SQL Compilations/sec")); + } + + /// A counter that seldom fires: one deadlock in 300 s is 0.0033 a second, and the total of 37 is the + /// count a reader of the grid has. Neither reads as 0. + [Fact] + public void ARateThatSeldomFires_ShowsItsRealRate_BesideItsTotal() + { + if (!TryRun("mixed", out var r)) return; + + Assert.Equal(new[] { "Number of Deadlocks/sec", "—", "0.0033", "37", "—", "1.00" }, Row(r, "Number of Deadlocks/sec")); } /// A gauge's value is its reading, and an average's numerator keeps its value and delta: only a rate's @@ -93,8 +111,8 @@ 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")); + 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] @@ -107,6 +125,35 @@ public void TheTrendChart_DrawsARateCounterPerSecond() Assert.Equal("per_second", line.GetProperty("key").GetString()); Assert.Equal("Per second", line.GetProperty("label").GetString()); Assert.Equal("/s", chart.GetProperty("unit").GetString()); + + /* The first point's rate was not knowable, so it reads as no number; the second is 66 over 300 s. */ + Assert.Equal(new[] { new[] { "Per second", "—" } }, Tooltip(chart, "left")); + Assert.Equal(new[] { new[] { "Per second", "0.22" } }, Tooltip(chart, "right")); + } + + /// One deadlock in a 300 s collection is 0.0033 a second. Printed to two decimals it was 0, on every + /// label of the value axis (0 0 0 0 0) and in the tooltip. Only an idle interval, a true 0, reads as 0. + [Fact] + public void TheTrendChart_ForARateThatSeldomFires_NeverReadsItsRateAsZero() + { + if (!TryRun("lowrate", out var r)) return; + + var chart = Assert.Single(r.GetProperty("charts").EnumerateArray()); + Assert.Equal(new[] { "0", "0.001", "0.002", "0.003", "0.004" }, Strings(chart.GetProperty("axis"))); + Assert.Equal(new[] { new[] { "Per second", "0.0033" } }, Tooltip(chart, "right")); + Assert.Equal(new[] { new[] { "Per second", "0" } }, Tooltip(chart, "left")); + } + + /// A bucket a day wide holds 86,400 s, so one deadlock in it is 0.000012 a second: smaller than any + /// rate a short bucket can show, and still not 0. + [Fact] + public void TheTrendChart_ForALongRangeBucket_NeverReadsItsRateAsZero() + { + if (!TryRun("longbucket", out var r)) return; + + var chart = Assert.Single(r.GetProperty("charts").EnumerateArray()); + Assert.Equal(new[] { "0", "0.000005", "0.00001", "0.000015" }, Strings(chart.GetProperty("axis"))); + Assert.Equal(new[] { new[] { "Per second", "0.000012" } }, Tooltip(chart, "right")); } /// A gauge's chart is what it was: its reading, and the delta line its points leave empty. @@ -119,5 +166,9 @@ public void TheTrendChart_ForAGauge_IsUnchanged() 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); + + /* ...and it prints as it did: grouped whole numbers, with no digits added for it. */ + Assert.Equal(new[] { "0", "2,000,000", "4,000,000", "6,000,000", "8,000,000" }, Strings(chart.GetProperty("axis"))); + Assert.Equal(new[] { new[] { "Value", "8,000,000" }, new[] { "Delta", "—" } }, Tooltip(chart, "right")); } } diff --git a/Darling/Darling.Tests/web-perfmon-harness.mjs b/Darling/Darling.Tests/web-perfmon-harness.mjs index 2adc0c5e6..ec815f437 100644 --- a/Darling/Darling.Tests/web-perfmon-harness.mjs +++ b/Darling/Darling.Tests/web-perfmon-harness.mjs @@ -1,10 +1,12 @@ -/* 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 +/* Runs the web viewer's Perfmon panel (perfmonPanel in wwwroot/js/pages/server-tabs.js, with util.js, panels.js and + charts.js) against a scripted /api/read answer and prints the grid it drew and the chart it drew (its lines, its + unit, the labels on its value axis and what its tooltip says at each end), 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. */ + The modules are copied into a scratch folder and imported. charts.js is the shipped renderer, behind a thin + wrapper that records what each chart was asked to draw and the value labels it printed. `fetch` and the DOM are + stand-ins: a node tree of plain objects that keeps the listeners it is given, 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"; @@ -20,6 +22,7 @@ class FakeNode { this.dataset = {}; this.style = {}; this.className = ""; + this.listeners = {}; this.text = text == null ? null : String(text); this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false }; } @@ -41,7 +44,12 @@ class FakeNode { getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; } - addEventListener() {} + addEventListener(type, listener) { + (this.listeners[type] = this.listeners[type] || []).push(listener); + } + getBoundingClientRect() { + return { left: 0, top: 0, width: 1000, height: 320 }; + } set textContent(value) { this.children = []; this.text = String(value); @@ -70,8 +78,9 @@ globalThis.fetch = async (url) => { 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. */ +/* The modules, unchanged. charts.js is the shipped renderer (copied as charts-real.js) behind a wrapper that records, + for each chart, the lines and the unit it was asked to draw, the chart node itself, and the labels it printed + while drawing: the value axis's tick labels, which are the only values it formats before a pointer arrives. */ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "perfmon-grid-")); let modules; try { @@ -80,15 +89,20 @@ try { 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.copyFileSync(path.join(jsDir, "charts.js"), path.join(scratch, "charts-real.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" + + 'import { renderLineChart as drawLineChart } from "./charts-real.js";\n' + + 'export * from "./charts-real.js";\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" + + " const axis = [];\n" + + " let drawing = true;\n" + + " const format = opts.formatValue || ((v) => String(v));\n" + + " const node = drawLineChart({ ...opts, formatValue: (v) => { const text = format(v); if (drawing) axis.push(text); return text; } });\n" + + " drawing = false;\n" + + " chartCalls.push({ series: (opts.series || []).map((s) => ({ key: s.key, label: s.label })), unit: opts.unit == null ? null : opts.unit, axis, node });\n" + + " return node;\n" + "}\n" ); const load = (rel) => import(pathToFileURL(path.join(scratch, rel)).href); @@ -108,19 +122,24 @@ const tool = (url) => url.pathname.replace("/api/read/", ""); 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 }; +// One deadlock in the 300 s since the last collection: 37 since the counter started, 1 / 300 = 0.0033 a second. +const DEADLOCKS = { counter_name: "Number of Deadlocks/sec", instance_name: "", value: 37, delta_value: 1, cntr_type: 272696576, counter_kind: "rate", per_second: 0.0033 }; 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 }); +// A naive-UTC stamp the way the trend payload prints one, this many minutes before now. +const ago = (minutes) => new Date(Date.now() - minutes * 60000).toISOString().slice(0, 19); + 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 }, + { time: ago(5), value: 11000, delta_value: 0, sample_interval_seconds: 0, per_second: null, peak_per_second: null }, + { time: ago(0), value: 11641, delta_value: 66, sample_interval_seconds: 300, per_second: 0.22, peak_per_second: 0.22 }, ], discontinuities: [], }; @@ -129,14 +148,42 @@ const GAUGE_TREND = { 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 }], + trend: [{ time: ago(0), value: 8000000, delta_value: null, sample_interval_seconds: null, peak_value: 8000000 }], + discontinuities: [], +}; + +// The deadlock counter over a day of 5-minute points: idle, idle, then one deadlock in 300 s (per_second 0.0033). +const LOW_RATE_TREND = { + server: "SRV1", + counter_name: "Number of Deadlocks/sec", + cntr_type: 272696576, + counter_kind: "rate", + trend: [ + { time: ago(15), value: 36, delta_value: 0, sample_interval_seconds: 300, per_second: 0, peak_per_second: 0 }, + { time: ago(10), value: 36, delta_value: 0, sample_interval_seconds: 300, per_second: 0, peak_per_second: 0 }, + { time: ago(5), value: 37, delta_value: 1, sample_interval_seconds: 300, per_second: 0.0033, peak_per_second: 0.0033 }, + ], + discontinuities: [], +}; + +// The same counter over a week in day-wide buckets: one deadlock in 86,400 s is 0.000012 a second. +const LONG_BUCKET_TREND = { + server: "SRV1", + counter_name: "Number of Deadlocks/sec", + cntr_type: 272696576, + counter_kind: "rate", + trend: [ + { time: ago(3 * 1440), value: 36, delta_value: 0, sample_interval_seconds: 86400, per_second: 0, peak_per_second: 0 }, + { time: ago(2 * 1440), value: 36, delta_value: 0, sample_interval_seconds: 86400, per_second: 0, peak_per_second: 0 }, + { time: ago(1440), value: 37, delta_value: 1, sample_interval_seconds: 86400, per_second: 0.000012, peak_per_second: 0.0033 }, + ], 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)); + answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([MEMORY, BATCHES, AVERAGE, COMPILES, DEADLOCKS]) : 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. @@ -144,6 +191,16 @@ const scenarios = { answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([MEMORY]) : data(GAUGE_TREND)); return modules.tabs.perfmonPanel("SRV1", { hours: 24, label: "last 24 hours" }); }, + // One rate that seldom fires: its chart's axis and tooltip must not read 0 for it. + lowrate: () => { + answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([DEADLOCKS]) : data(LOW_RATE_TREND)); + return modules.tabs.perfmonPanel("SRV1", { hours: 24, label: "last 24 hours" }); + }, + // The same rate over a long range, where one bucket spans a day. + longbucket: () => { + answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([DEADLOCKS]) : data(LONG_BUCKET_TREND)); + return modules.tabs.perfmonPanel("SRV1", { hours: 168, label: "last 7 days" }); + }, }; const chosen = scenarios[scenario]; @@ -162,10 +219,40 @@ const all = (node, tag, found = []) => { }; const table = all(root, "table")[0] || null; +/* What a chart's tooltip says with the pointer at its left edge and at its right edge: the nearest point's rows, as + [label, value] pairs. The chart registers one mousemove listener, on its overlay. */ +const withListener = (node, type) => { + if (!node || typeof node !== "object") return null; + if (node.listeners && node.listeners[type]) return node; + for (const child of node.children) { + const found = withListener(child, type); + if (found) return found; + } + return null; +}; +const tooltipAt = (chart, clientX) => { + const overlay = withListener(chart.node, "mousemove"); + if (!overlay) return null; + overlay.listeners.mousemove[0]({ clientX }); + const tip = all(chart.node, "div").find((n) => n.className === "chart-tooltip"); + return all(tip, "div") + .filter((n) => n.className === "t-row") + .map((row) => { + const spans = all(row, "span"); + return [spans[1].textContent, spans[2].textContent]; + }); +}; +const charts = modules.charts.chartCalls.map((chart) => ({ + series: chart.series, + unit: chart.unit, + axis: chart.axis, + tooltips: { left: tooltipAt(chart, 0), right: tooltipAt(chart, 1000) }, +})); + 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, + charts, errors: all(root, "div").filter((n) => n.className === "strip error").map((n) => n.textContent), fetches, rejections, diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js index 5905fbb08..53cc4342d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js @@ -38,7 +38,7 @@ import { buildRefreshControl } from "./refresh-control.js"; import * as derive from "./derive.js"; /** The FORMATTERS keys the format pickers offer (mirrors util.js FORMATTERS). */ -const FORMAT_OPTIONS = ["text", "int", "num1", "num2", "pct", "ms", "mb", "time", "reltime", "bool"]; +const FORMAT_OPTIONS = ["text", "int", "num1", "num2", "rate", "pct", "ms", "mb", "time", "reltime", "bool"]; const PREVIEW_DEBOUNCE_MS = 350; /** The view-level default time-range choices (hours) offered in the composer + the rendered view's chrome. */ 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 e37a46731..7b3b44014 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -39,7 +39,7 @@ * touches innerHTML. */ -import { el, readTool, readToolWithinKeptHistory, keptWindowStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, localTime, windowFromHours } from "../util.js"; +import { el, readTool, readToolWithinKeptHistory, keptWindowStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, fmtRate, localTime, windowFromHours } from "../util.js"; import { renderPanel, VIZ } from "../panels.js"; import { renderLineChart, SERIES_COLORS } from "../charts.js"; @@ -309,7 +309,6 @@ async function drawPerfmonTrend(slot, server, ctx, counterName) { points: trend.data.trend || [], xKey: "time", ...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. A narrowed read spans the hours it answered for. */ ...windowFromHours(trend.keptHours || ctx.hours), @@ -317,29 +316,35 @@ 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. */ +/* A rate counter's stored value is its running total since the counter started, not a rate. The Perfmon grid shows + such a row two ways: the per-second figure the server worked out for it (per_second, a key only a rate row + carries) under Per second, and the running total under Total since start, a header that says what the number is. + The total stays whether or not a rate is known, because for a counter that seldom fires it is the only count + there is: a deadlock counter that has fired 37 times can read 0.0033 a 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. A gauge's value is its reading and stays under Value, 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 + c && "per_second" in c + ? { ...c, running_total: c.value, 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. */ + charts plot for it, and that is the one line: its value only climbs. Its axis and tooltip print through fmtRate, + so a small real rate never reads as 0. Every other counter keeps its value and delta lines and its own number + format, 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: "per_second", label: "Per second", color: SERIES_COLORS[0] }], unit: "/s", formatValue: fmtRate }; } return { series: [ { key: "value", label: "Value", color: SERIES_COLORS[0] }, { key: "delta_value", label: "Delta", color: SERIES_COLORS[1] }, ], + formatValue: (v) => Number(v).toLocaleString(undefined, { maximumFractionDigits: 2 }), }; } @@ -3251,7 +3256,8 @@ 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: "per_second", label: "Per second", format: "rate" }, + { key: "running_total", label: "Total since start", format: "int" }, { key: "value", label: "Value", format: "num2" }, { key: "delta_value", label: "Delta", format: "num2" }, ]; diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js index 8ebfedcab..f962ee85e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js @@ -184,7 +184,7 @@ function vizTable(data, desc) { } function isNumericCol(c) { - return c.align === "right" || ["int", "num1", "num2", "ms", "mb", "pct"].includes(c.format); + return c.align === "right" || ["int", "num1", "num2", "rate", "ms", "mb", "pct"].includes(c.format); } function cell(row, c) { diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js index ba4365c1e..31637ef68 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js @@ -283,6 +283,17 @@ export function fmtNum(v, d = 1) { const n = Number(v); return isFinite(n) ? n.toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d }) : "—"; } +/* A per-second rate. From 1 up it reads as fmtNum does without padding (66, 1,234.57). Below 1 it keeps two + significant digits (0.22, 0.0033, 0.000012), so a real rate never reads as 0: one deadlock in a 300 s collection + is 0.0033 a second, and one count over a day-wide bucket is 0.000012. Only a true 0 reads 0. */ +export function fmtRate(v) { + if (v == null) return "—"; + const n = Number(v); + if (!isFinite(n)) return "—"; + return Math.abs(n) >= 1 || n === 0 + ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) + : n.toLocaleString(undefined, { maximumSignificantDigits: 2 }); +} export function fmtPct(v) { if (v == null) return "—"; const n = Number(v); @@ -316,6 +327,7 @@ export const FORMATTERS = { int: fmtInt, num1: (v) => fmtNum(v, 1), num2: (v) => fmtNum(v, 2), + rate: fmtRate, pct: fmtPct, ms: fmtMs, mb: fmtMb, diff --git a/Lite.Tests/MeasurementContractCensusTests.cs b/Lite.Tests/MeasurementContractCensusTests.cs index 2b5a7bf17..f1922689c 100644 --- a/Lite.Tests/MeasurementContractCensusTests.cs +++ b/Lite.Tests/MeasurementContractCensusTests.cs @@ -1045,8 +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), - ("per_second", RateHelper), /* a bare key, no prefix: swept all the same */ /* a local nothing explains, under a name no SQL divides for */ + ("unknown_per_sec", UnderAnotherName), /* a local nothing explains, under a name no SQL divides for */ + ("per_second", RateHelper), /* a bare key, no prefix: swept all the same */ ("added_per_sec", Quotient), /* Add(key, total / elapsed.TotalSeconds) */ ("divided_per_second", Quotient), ("helped_per_second", RateHelper), diff --git a/Lite.Tests/PerfmonPerSecondRoundingTests.cs b/Lite.Tests/PerfmonPerSecondRoundingTests.cs new file mode 100644 index 000000000..6da1086b2 --- /dev/null +++ b/Lite.Tests/PerfmonPerSecondRoundingTests.cs @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Text.Json; +using PerformanceMonitor.Common; +using Xunit; + +namespace PerformanceMonitorLite.Tests; + +/// +/// How the perfmon payloads round a per-second figure. Four decimals print a rate under 0.00005 as 0, and a bucket can +/// be a day wide: one count in 86,400 s is 0.0000116 a second, so a nonzero delta published as a rate of 0, which a +/// reader takes for "nothing happened". keeps the four decimals every +/// payload has published and gives a rate that small two significant digits instead. Lite and Darling build their +/// get_perfmon_stats rows and get_perfmon_trend points through the same TrendPayloads builders, +/// so these pins hold for both; the twin of the payload pins is in PerfmonPerSecondPayloadTests in Darling.Tests. +/// +public sealed class PerfmonPerSecondRoundingTests +{ + private static readonly DateTime Bucket0 = new(2026, 9, 1, 0, 0, 0, DateTimeKind.Unspecified); + + [Fact] + public void RoundRate_KeepsFourDecimals_AndTwoSignificantDigitsWhereFourWouldKeepFewer() + { + /* From 0.001 up, four decimals, as every perfmon payload has published them. */ + Assert.Equal(0.22, TrendPayloads.RoundRate(66.0 / 300), precision: 12); + Assert.Equal(0.2333, TrendPayloads.RoundRate(70.0 / 300), precision: 12); + Assert.Equal(0.0033, TrendPayloads.RoundRate(1.0 / 300), precision: 12); + Assert.Equal(11641.2346, TrendPayloads.RoundRate(11641.234567), precision: 12); + + /* Below it, two significant digits: one count over an hour, over six hours, over a day. */ + Assert.Equal(0.00028, TrendPayloads.RoundRate(1.0 / 3600), precision: 12); + Assert.Equal(0.000046, TrendPayloads.RoundRate(1.0 / 21600), precision: 12); + Assert.Equal(0.000012, TrendPayloads.RoundRate(1.0 / 86400), precision: 12); + + /* A true zero is a zero, and a value that is not a number passes through untouched. */ + Assert.Equal(0.0, TrendPayloads.RoundRate(0)); + Assert.True(double.IsNaN(TrendPayloads.RoundRate(double.NaN))); + } + + private static PerfmonBucketPoint Bucket(long delta, long seconds, double? peak = null) => + new(Bucket0, AvgValue: 0, MaxValue: 0, LastValue: 37, + RatedDelta: delta, RatedSeconds: seconds, UnknowableCollections: 0, UnknowableDelta: null, UnrecordedDelta: null, + PeakPerSecond: peak ?? delta / 300.0, CntrType: PerfmonCounterTypes.PerfCounterBulkCount); + + private static JsonElement TrendPoint(PerfmonBucketPoint point, int bucketMinutes) + { + using var doc = JsonDocument.Parse(TrendPayloads.PerfmonTrend( + "SRV1", "Number of Deadlocks/sec", 168, new[] { point }, bucketMinutes, requested: true, + autoBudget: TrendBuckets.McpPointBudget, discontinuities: Array.Empty())); + return doc.RootElement.GetProperty("trend")[0].Clone(); + } + + /// The largest bucket a caller can ask for is a day, 86,400 s. One deadlock in it is a rate that four + /// decimals erase; it publishes as the rate it is. + [Fact] + public void OneCountOverTheLargestBucket_PublishesItsRate_NotZero() + { + var seconds = TrendBuckets.MaxBucketMinutes * 60L; + + var point = TrendPoint(Bucket(1, seconds), TrendBuckets.MaxBucketMinutes); + + Assert.Equal(0.000012, point.GetProperty("per_second").GetDouble(), precision: 12); + Assert.Equal(1, point.GetProperty("delta_value").GetInt64()); + Assert.Equal(seconds, point.GetProperty("sample_interval_seconds").GetInt64()); + } + + /// No bucket width on the ladder, nor the day-wide maximum, turns a single count into a rate of 0, and + /// each publishes it within the two digits' own rounding (5%) of delta over seconds. + [Fact] + public void OneCountOverEveryBucketWidth_PublishesANonzeroRate() + { + foreach (var width in TrendBuckets.LadderMinutes) + { + var seconds = width * 60L; + + var rate = TrendPoint(Bucket(1, seconds), width).GetProperty("per_second").GetDouble(); + + Assert.True(rate > 0, $"a {width}-minute bucket with one count published a per_second of 0"); + Assert.InRange(rate * seconds, 0.95, 1.05); + } + } + + /// The busiest single collection is rated by the same rule: one count in the longest interval the + /// calculator rates (3,600 s) keeps its two digits. + [Fact] + public void ThePeakRate_IsRoundedByTheSameRule() + { + var point = TrendPoint(Bucket(1, 86400, peak: 1.0 / 3600), 1440); + + Assert.Equal(0.00028, point.GetProperty("peak_per_second").GetDouble(), precision: 12); + } + + /// The latest-snapshot row is rated by the same rule: one count in the longest interval the calculator + /// rates keeps two significant digits, and the row keeps the running total beside it. + [Fact] + public void ALatestRow_IsRoundedByTheSameRule_AndKeepsItsTotal() + { + var row = TrendPayloads.PerfmonLatestRow("Number of Deadlocks/sec", "", 37, 1, 3600, PerfmonCounterTypes.PerfCounterBulkCount); + + Assert.Equal(0.00028, Assert.IsType(row["per_second"]), precision: 12); + Assert.Equal(37L, row["value"]); + } +} diff --git a/PerformanceMonitor.Common/Mcp/TrendPayloads.cs b/PerformanceMonitor.Common/Mcp/TrendPayloads.cs index a5d800227..516647459 100644 --- a/PerformanceMonitor.Common/Mcp/TrendPayloads.cs +++ b/PerformanceMonitor.Common/Mcp/TrendPayloads.cs @@ -451,7 +451,8 @@ public static string MemoryTrend( /// 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, 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. + /// 0 or missing), and peak_per_second, its busiest single collection. Both rates are rounded by + /// , so a nonzero delta never publishes as a rate of 0, even over a bucket a day wide. /// public static string PerfmonTrend( string serverName, string counterName, int hoursBack, IReadOnlyList points, @@ -521,8 +522,8 @@ since summing the survivors would then undercount. */ if (basis == DeltaBasis.PerSecond) { - 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; + point["per_second"] = DeltaSeriesShaping.PerSecond(delta, seconds) is { } rate ? RoundRate(rate) : null; + point["peak_per_second"] = p.PeakPerSecond is { } peak ? RoundRate(peak) : null; } return point; @@ -535,8 +536,9 @@ since summing the survivors would then undercount. */ /// (). 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. + /// (, rounded by ), 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) @@ -554,13 +556,32 @@ since summing the survivors would then undercount. */ if (DeltaSeriesShaping.BasisFor(counterName, cntrType) == DeltaBasis.PerSecond) { row["per_second"] = DeltaSeriesShaping.PerSecond(deltaValue, sampleIntervalSeconds) is { } rate - ? Math.Round(rate, 4) + ? RoundRate(rate) : null; } return row; } + /// + /// A per-second figure as a perfmon payload publishes it: four decimals, and two significant digits where four + /// decimals would keep fewer. A rate under 0.001 is a count or two over a long span (one count in a day-wide bucket + /// of 86,400 s is 0.0000116 a second), and four decimals print it as 0.0001, or as 0 once it is under 0.00005, which + /// says nothing happened. Only a true 0 publishes as 0. Every rate and + /// publish is rounded here, so Lite and Darling publish the same number for the same + /// count over the same span. + /// + internal static double RoundRate(double rate) + { + if (rate == 0 || !double.IsFinite(rate)) + { + return rate; + } + + var decimals = Math.Clamp(1 - (int)Math.Floor(Math.Log10(Math.Abs(rate))), 4, 15); + return Math.Round(rate, decimals); + } + /// 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 b4ec6f4d77d3b197a1362f521030da89ac7e876d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:15:11 -0400 Subject: [PATCH 4/7] Web perfmon pins: the number columns line up on the right --- Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs | 5 +++++ Darling/Darling.Tests/web-perfmon-harness.mjs | 1 + 2 files changed, 6 insertions(+) diff --git a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs index ab4ecc02e..8d96d5c14 100644 --- a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs +++ b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs @@ -81,6 +81,11 @@ public void TheGrid_ShowsARateCounterPerSecond_AndItsRunningTotalUnderATotalHead Assert.Equal(new[] { "Counter", "Instance", "Per second", "Total since start", "Value", "Delta" }, Strings(r.GetProperty("headers"))); Assert.Equal(new[] { "Batch Requests/sec", "—", "0.22", "11,641", "—", "66.00" }, Row(r, "Batch Requests/sec")); + + /* The four number columns line up on the right, as every number column in the grids does. */ + Assert.Equal( + new[] { false, false, true, true, true, true }, + r.GetProperty("numericHeaders").EnumerateArray().Select(h => h.GetBoolean()).ToArray()); } /// A rate with no knowable delta (a first collection, a counter reset, a restart) has no rate to show, diff --git a/Darling/Darling.Tests/web-perfmon-harness.mjs b/Darling/Darling.Tests/web-perfmon-harness.mjs index ec815f437..1f7e94843 100644 --- a/Darling/Darling.Tests/web-perfmon-harness.mjs +++ b/Darling/Darling.Tests/web-perfmon-harness.mjs @@ -251,6 +251,7 @@ const charts = modules.charts.chartCalls.map((chart) => ({ console.log(JSON.stringify({ headers: table ? all(table, "th").map((th) => th.textContent) : [], + numericHeaders: table ? all(table, "th").map((th) => th.className === "num") : [], rows: table ? all(table, "tr").map((tr) => all(tr, "td").map((td) => td.textContent)).filter((cells) => cells.length) : [], charts, errors: all(root, "div").filter((n) => n.className === "strip error").map((n) => n.textContent), From 3dcbeb285576df0bf7497e84a63b26928906af0a Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:20:56 -0400 Subject: [PATCH 5/7] Perfmon grid comment: wrap the long line --- .../wwwroot/js/pages/server-tabs.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 7b3b44014..3677faaaf 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -320,9 +320,10 @@ async function drawPerfmonTrend(slot, server, ctx, counterName) { such a row two ways: the per-second figure the server worked out for it (per_second, a key only a rate row carries) under Per second, and the running total under Total since start, a header that says what the number is. The total stays whether or not a rate is known, because for a counter that seldom fires it is the only count - there is: a deadlock counter that has fired 37 times can read 0.0033 a 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. A gauge's value is its reading and stays under Value, and so does any other row's. */ + there is: a deadlock counter that has fired 37 times can read 0.0033 a 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. A gauge's value is its reading and stays under Value, and so does any + other row's. */ function perfmonRows(counters) { return (counters || []).map((c) => c && "per_second" in c From d4ed94ae5430744a7f832ea9b519e259185eb32c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:14:45 -0400 Subject: [PATCH 6/7] Perfmon grid: the running-total header reads Total since counter start The cell is the raw counter value, which counts from the counter's own start: an instance restart, or a database's own restart for a per-database counter. "Since start" did not say whose start. --- Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs | 6 +++--- .../wwwroot/js/pages/server-tabs.js | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs index 8d96d5c14..3c73c2984 100644 --- a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs +++ b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs @@ -20,8 +20,8 @@ namespace Darling.Tests; /// 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 the running total under a -/// Total since start header, and the trend chart draws the rate's per_second line, its axis labels and its -/// tooltip printed so that a real rate never reads as 0. A gauge keeps its reading in the grid and its chart. +/// Total since counter start header, and the trend chart draws the rate's per_second line, its axis labels +/// and its tooltip printed so that a real rate never reads as 0. 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 @@ -79,7 +79,7 @@ public void TheGrid_ShowsARateCounterPerSecond_AndItsRunningTotalUnderATotalHead { if (!TryRun("mixed", out var r)) return; - Assert.Equal(new[] { "Counter", "Instance", "Per second", "Total since start", "Value", "Delta" }, Strings(r.GetProperty("headers"))); + Assert.Equal(new[] { "Counter", "Instance", "Per second", "Total since counter start", "Value", "Delta" }, Strings(r.GetProperty("headers"))); Assert.Equal(new[] { "Batch Requests/sec", "—", "0.22", "11,641", "—", "66.00" }, Row(r, "Batch Requests/sec")); /* The four number columns line up on the right, as every number column in the grids does. */ 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 3677faaaf..0c40e4754 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js @@ -318,7 +318,9 @@ async function drawPerfmonTrend(slot, server, ctx, counterName) { /* A rate counter's stored value is its running total since the counter started, not a rate. The Perfmon grid shows such a row two ways: the per-second figure the server worked out for it (per_second, a key only a rate row - carries) under Per second, and the running total under Total since start, a header that says what the number is. + carries) under Per second, and the running total under Total since counter start. The header says what the + number is: the raw counter value, which counts from the counter's own start (an instance restart, or a + database's own restart for a per-database counter), not from when monitoring began. The total stays whether or not a rate is known, because for a counter that seldom fires it is the only count there is: a deadlock counter that has fired 37 times can read 0.0033 a 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 @@ -3258,7 +3260,7 @@ const PERFMON_COLUMNS = [ { key: "counter_name", label: "Counter" }, { key: "instance_name", label: "Instance" }, { key: "per_second", label: "Per second", format: "rate" }, - { key: "running_total", label: "Total since start", format: "int" }, + { key: "running_total", label: "Total since counter start", format: "int" }, { key: "value", label: "Value", format: "num2" }, { key: "delta_value", label: "Delta", format: "num2" }, ]; From 176e6828af0d46e57958a7f06d264ff83a4e1c18 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:17:02 -0400 Subject: [PATCH 7/7] Web perfmon pins: the rate format's digit rules Every scripted rate had two significant digits, so changing fmtRate to keep three left the suite green. Two new rows, 0.2333 and 1234.5678, pin a rate below 1 at two significant digits and a rate of 1 or more at two decimals, in the grid cell and in the chart tooltip. --- .../WebPerfmonPerSecondBehaviourTests.cs | 24 ++++++++++++++++++ Darling/Darling.Tests/web-perfmon-harness.mjs | 25 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs index 3c73c2984..cf8589765 100644 --- a/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs +++ b/Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs @@ -109,6 +109,18 @@ public void ARateThatSeldomFires_ShowsItsRealRate_BesideItsTotal() Assert.Equal(new[] { "Number of Deadlocks/sec", "—", "0.0033", "37", "—", "1.00" }, Row(r, "Number of Deadlocks/sec")); } + /// The rate format's digit rules, in the grid cell. A rate below 1 keeps two significant digits: 0.2333 (70 in + /// 300 s) reads 0.23, not 0.233 or 0.2333. A rate of 1 or more keeps up to two decimals and groups its thousands: + /// 1234.5678 (1,111,111 in 900 s) reads 1,234.57. The total and the delta beside each keep their own formats. + [Fact] + public void TheGrid_PrintsARateBelowOneToTwoSignificantDigits_AndARateOfOneOrMoreToTwoDecimals() + { + if (!TryRun("digits", out var r)) return; + + Assert.Equal(new[] { "Page Splits/sec", "—", "0.23", "1,113,211", "—", "70.00" }, Row(r, "Page Splits/sec")); + Assert.Equal(new[] { "Transactions/sec", "—", "1,234.57", "4,000,000", "—", "1,111,111.00" }, Row(r, "Transactions/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] @@ -161,6 +173,18 @@ public void TheTrendChart_ForALongRangeBucket_NeverReadsItsRateAsZero() Assert.Equal(new[] { new[] { "Per second", "0.000012" } }, Tooltip(chart, "right")); } + /// The same two rules in the trend chart's tooltip: the older point's 1234.5678 reads 1,234.57, and the + /// newer point's 0.2333 reads 0.23. + [Fact] + public void TheTrendChart_PrintsARateBelowOneToTwoSignificantDigits_AndARateOfOneOrMoreToTwoDecimals() + { + if (!TryRun("digits", out var r)) return; + + var chart = Assert.Single(r.GetProperty("charts").EnumerateArray()); + Assert.Equal(new[] { new[] { "Per second", "1,234.57" } }, Tooltip(chart, "left")); + Assert.Equal(new[] { new[] { "Per second", "0.23" } }, Tooltip(chart, "right")); + } + /// A gauge's chart is what it was: its reading, and the delta line its points leave empty. [Fact] public void TheTrendChart_ForAGauge_IsUnchanged() diff --git a/Darling/Darling.Tests/web-perfmon-harness.mjs b/Darling/Darling.Tests/web-perfmon-harness.mjs index 1f7e94843..b18303cc3 100644 --- a/Darling/Darling.Tests/web-perfmon-harness.mjs +++ b/Darling/Darling.Tests/web-perfmon-harness.mjs @@ -124,6 +124,11 @@ const BATCHES = { counter_name: "Batch Requests/sec", instance_name: "", value: const COMPILES = { counter_name: "SQL Compilations/sec", instance_name: "", value: 4000, delta_value: 0, cntr_type: 272696576, counter_kind: "rate", per_second: null }; // One deadlock in the 300 s since the last collection: 37 since the counter started, 1 / 300 = 0.0033 a second. const DEADLOCKS = { counter_name: "Number of Deadlocks/sec", instance_name: "", value: 37, delta_value: 1, cntr_type: 272696576, counter_kind: "rate", per_second: 0.0033 }; +// Two rates with more digits than the rate format keeps, as the server publishes them (four decimals). 70 in the 300 s since +// the last collection is 70 / 300 = 0.2333 a second; 1,111,111 in 900 s is 1234.5678 a second. Below 1 the format keeps two +// significant digits (0.23), and from 1 up it keeps two decimals (1,234.57). +const PAGE_SPLITS = { counter_name: "Page Splits/sec", instance_name: "", value: 1113211, delta_value: 70, cntr_type: 272696576, counter_kind: "rate", per_second: 0.2333 }; +const TRANSACTIONS = { counter_name: "Transactions/sec", instance_name: "", value: 4000000, delta_value: 1111111, cntr_type: 272696576, counter_kind: "rate", per_second: 1234.5678 }; 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" }; @@ -166,6 +171,20 @@ const LOW_RATE_TREND = { discontinuities: [], }; +// Page Splits/sec over its last two collections: a burst of 1,111,111 in 900 s (1234.5678 a second) took its total from 2,030 to +// 1,113,141, then 70 in 300 s (0.2333 a second) took it to 1,113,211, the total its grid row shows. +const DIGITS_TREND = { + server: "SRV1", + counter_name: "Page Splits/sec", + cntr_type: 272696576, + counter_kind: "rate", + trend: [ + { time: ago(5), value: 1113141, delta_value: 1111111, sample_interval_seconds: 900, per_second: 1234.5678, peak_per_second: 1234.5678 }, + { time: ago(0), value: 1113211, delta_value: 70, sample_interval_seconds: 300, per_second: 0.2333, peak_per_second: 0.2333 }, + ], + discontinuities: [], +}; + // The same counter over a week in day-wide buckets: one deadlock in 86,400 s is 0.000012 a second. const LONG_BUCKET_TREND = { server: "SRV1", @@ -201,6 +220,12 @@ const scenarios = { answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([DEADLOCKS]) : data(LONG_BUCKET_TREND)); return modules.tabs.perfmonPanel("SRV1", { hours: 168, label: "last 7 days" }); }, + // Two rates with more digits than the rate format keeps: the grid cells, and the chart's tooltip at each end. The picker opens on + // the first name, Page Splits/sec, the counter DIGITS_TREND is for. + digits: () => { + answer = (url) => (tool(url) === "get_perfmon_stats" ? stats([TRANSACTIONS, PAGE_SPLITS]) : data(DIGITS_TREND)); + return modules.tabs.perfmonPanel("SRV1", { hours: 24, label: "last 24 hours" }); + }, }; const chosen = scenarios[scenario];