Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public void AuthoredTemplate_RoutesEveryRegisteredMetric_ToItsOwnTemplate(string
[Fact]
public void AuthoredTemplate_NonAuthoredMetric_StaysMechanical()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU");
var template = AlertNotebookEndpoint.AuthoredTemplate("tempdb Space");

Assert.Null(template);
}
Expand Down Expand Up @@ -244,6 +244,16 @@ public void Budget_ReadCellsHaveLimitsExceptTheTrendRead_ComposedWindowsAreAtMos
{
Assert.False(parameters.ContainsKey("limit"), $"{read} must not carry a limit param");
}
else if (read == "get_top_queries_by_cpu" || read == "get_top_procedures_by_cpu")
{
/* #4223: these two declare no 'limit' param -- they cap with 'top' instead. */
Assert.True(parameters.ContainsKey("top"), $"read cell '{read}' must carry a top param");
}
else if (read == "get_cpu_scheduler_pressure")
{
/* #4223: a newest-snapshot read (DarlingWebEndpoints' own catalog entry) -- no row cap to carry. */
Assert.False(parameters.ContainsKey("limit"), "get_cpu_scheduler_pressure must not carry a limit param");
}
else
{
Assert.True(parameters.ContainsKey("limit"), $"read cell '{read}' must carry a limit param");
Expand Down
8 changes: 6 additions & 2 deletions Darling/Darling.Tests/AlertNotebookEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -645,8 +645,12 @@ public async Task AuthenticatedRequest_ReturnsTheDocumentedNotebookShape()
await using var deadStore = DeadStore();
using var server = await BuildServer(deadStore);

/* #4223 gave "High CPU" an authored template -- this shape check now needs a metric that STAYS
mechanical, so it exercises the fallback path it documents rather than an authored one. "Poison
Wait" is excluded here because a later PR makes it authored too; "tempdb Space" has no row in
s_authoredTemplates. */
var ctx = await SendAuthenticated(
server, "/api/alert-notebook?server=probe&metric=" + Uri.EscapeDataString("High CPU"),
server, "/api/alert-notebook?server=probe&metric=" + Uri.EscapeDataString("tempdb Space"),
IPAddress.Parse("192.168.1.50"));

Assert.Equal(StatusCodes.Status200OK, ctx.Response.StatusCode);
Expand Down Expand Up @@ -676,6 +680,6 @@ public async Task AuthenticatedRequest_ReturnsTheDocumentedNotebookShape()
Assert.True(cellParams.TryGetProperty("hours", out _), "every mechanical read cell must carry an hours param");
}

Assert.Equal("mechanical/High CPU", root.GetProperty("template").GetProperty("id").GetString());
Assert.Equal("mechanical/tempdb Space", root.GetProperty("template").GetProperty("id").GetString());
}
}
107 changes: 107 additions & 0 deletions Darling/Darling.Tests/AlertNotebookTemplateCpuTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Text.Json.Nodes;
using PerformanceMonitor.Darling.Service;
using PerformanceMonitor.Notifications;
using Xunit;

namespace Darling.Tests;

/// <summary>
/// Pins for #4223's authored <c>authored/cpu</c> alert-notebook template
/// (<see cref="AlertNotebookEndpoint.AuthoredTemplate"/> / <c>BuildCpuCells</c>): routing off the "High CPU"
/// metric, the exact cell list (both engines' CPU timelines present, titled), and the no-matched-incident
/// degrade. The shared theories in <c>AlertNotebookAuthoredTemplateTests</c> (validation, dispatch names, the
/// compose catalog, budget, binding, viz) already cover this family via its
/// <see cref="AlertNotebookEndpoint.s_authoredTemplates"/> row — these pins are the family-specific shape only.
/// </summary>
public sealed class AlertNotebookTemplateCpuTests
{
private static readonly DateTime WindowEnd = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime WindowStart = WindowEnd - TimeSpan.FromHours(24);
private const string AsOf = "2026-01-01T12:00:00Z";

private static AlertIncident IncidentWithDatabase(string database) =>
new("dedup-key", new[] { "obj1" }, Database: database);

[Fact]
public void HighCpu_RoutesToTheAuthoredCpuTemplate()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU");

Assert.NotNull(template);
Assert.Equal("authored/cpu", template!.Value.Id);
}

[Fact]
public void BuildCpuCells_HasTheExactCellListWithBothEnginesPanelsTitled()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU");
Assert.NotNull(template);

var cells = template!.Value.BuildCells(
"High CPU", "SRV1", AsOf, WindowStart, WindowEnd, IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(7, cells.Count);

var header = Assert.IsType<JsonObject>(cells[0]);
Assert.Equal("header", (string)header["type"]!);

var status = Assert.IsType<JsonObject>(cells[1]);
Assert.Equal("status", (string)status["type"]!);

var sqlServerCpu = Assert.IsType<JsonObject>(cells[2]);
Assert.Equal("panel", (string)sqlServerCpu["type"]!);
Assert.Equal("SQL Server CPU", (string)sqlServerCpu["title"]!);
Assert.Equal("cpu_utilization_stats", (string)sqlServerCpu["source"]!);
Assert.Equal("sqlserver_cpu_utilization", (string)sqlServerCpu["measure"]!);

var pgCpu = Assert.IsType<JsonObject>(cells[3]);
Assert.Equal("panel", (string)pgCpu["type"]!);
Assert.Equal("PostgreSQL CPU", (string)pgCpu["title"]!);
Assert.Equal("pg_cpu_utilization", (string)pgCpu["source"]!);
Assert.Equal("pg_acu_utilization_pct", (string)pgCpu["measure"]!);

var topQueries = Assert.IsType<JsonObject>(cells[4]);
Assert.Equal("read", (string)topQueries["type"]!);
Assert.Equal("get_top_queries_by_cpu", (string)topQueries["read"]!);
Assert.Equal("Top queries by CPU", (string)topQueries["title"]!);

var topProcedures = Assert.IsType<JsonObject>(cells[5]);
Assert.Equal("read", (string)topProcedures["type"]!);
Assert.Equal("get_top_procedures_by_cpu", (string)topProcedures["read"]!);
Assert.Equal("Top procedures by CPU", (string)topProcedures["title"]!);

var scheduler = Assert.IsType<JsonObject>(cells[6]);
Assert.Equal("read", (string)scheduler["type"]!);
Assert.Equal("get_cpu_scheduler_pressure", (string)scheduler["read"]!);
Assert.Equal("Scheduler pressure", (string)scheduler["title"]!);
}

[Fact]
public void BuildCpuCells_WithNoMatchedIncident_StillDegradesWithTheSameCellCount()
{
var template = AlertNotebookEndpoint.AuthoredTemplate("High CPU");
Assert.NotNull(template);

var withoutIncident = template!.Value.BuildCells(
"High CPU", "SRV1", AsOf, WindowStart, WindowEnd, null, null,
"Unknown (not collected since " + AsOf + ")");

var withIncident = template.Value.BuildCells(
"High CPU", "SRV1", AsOf, WindowStart, WindowEnd, IncidentWithDatabase("SalesDb"), null, "Unknown");

Assert.Equal(withIncident.Count, withoutIncident.Count);

var definition = new JsonObject { ["kind"] = "notebook", ["cells"] = withoutIncident };
var validation = DarlingWebEndpoints.ValidateNotebookDefinition(definition);
Assert.True(validation.IsValid, validation.Error);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Erik Darling, Darling Data LLC
*
* This file is part of the SQL Server Performance Monitor.
*
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
*/

using System;
using System.Globalization;
using System.Text.Json.Nodes;
using PerformanceMonitor.Darling.Analysis;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Notifications;

namespace PerformanceMonitor.Darling.Service;

internal static partial class AlertNotebookEndpoint
{
/// <summary>High CPU template version (#4223). Bumped only if this template's SHAPE changes.</summary>
internal const int CpuTemplateVersion = 1;

/// <summary>High CPU (spec §3): header, status, a SQL Server CPU timeline and a PostgreSQL CPU timeline
/// (the metric fires on both engines since #2719, so both panels are always present — the wrong-engine
/// panel's own read simply comes back empty, the same "not applicable" shape
/// <see cref="DarlingTriageEndpoint.SectionsByMetric"/>'s "High CPU" row already uses), the top-queries and
/// top-procedures-by-CPU drill-downs, then scheduler pressure for the SQL Server-side follow-up.</summary>
private static JsonArray BuildCpuCells(
string? metric, string? serverName, string? asOf, DateTime windowStart, DateTime windowEnd,
AlertIncident? incident, DarlingAlertReader.AlertHistoryReadRow? row, string status)
{
var cells = new JsonArray
{
HeaderCell(metric, serverName, incident, row),
StatusCell(status),
CpuGaugeTimelinePanel("SQL Server CPU", "cpu_utilization_stats", "sqlserver_cpu_utilization", windowStart, windowEnd),
CpuGaugeTimelinePanel("PostgreSQL CPU", "pg_cpu_utilization", "pg_acu_utilization_pct", windowStart, windowEnd),
AuthoredReadCell("get_top_queries_by_cpu", "Top queries by CPU", serverName, asOf,
("hours", "24"), ("top", "10")),
AuthoredReadCell("get_top_procedures_by_cpu", "Top procedures by CPU", serverName, asOf,
("hours", "24"), ("top", "10")),
AuthoredReadCell("get_cpu_scheduler_pressure", "Scheduler pressure", serverName, asOf),
};

return cells;
}

/// <summary>Both CPU measures (<c>sqlserver_cpu_utilization</c>, <c>pg_acu_utilization_pct</c>) are Gauge
/// archetype (point-in-time percent readings, per <c>MeasureCatalog</c>'s own grain-trap note) — their
/// <c>ValidAggs</c> is avg/min/max, never <c>count</c>. <see cref="TimelinePanel"/> hardcodes
/// <c>aggregate: "count"</c> for the per-event deadlock/blocking sources it was built for, so a CPU
/// timeline needs its own cell rather than reusing that helper (widening it to a per-family aggregate
/// would change what every OTHER family's already-shipped panel emits). No annotation overlay: neither
/// deadlocks nor blocked-process reports nor a long-query completion is a CPU-specific marker, and the
/// spec doesn't ask for one here — see the PR body for why this template carries no core-helper
/// change.</summary>
private static JsonObject CpuGaugeTimelinePanel(string title, string source, string measure, DateTime windowStart, DateTime windowEnd) => new()
{
["type"] = "panel",
["title"] = title,
["source"] = source,
["measure"] = measure,
["aggregate"] = "avg",
["viz"] = "line",
["timeBucket"] = "hour",
["range"] = new JsonObject
{
["windowStart"] = windowStart.ToString("o", CultureInfo.InvariantCulture),
["windowEnd"] = windowEnd.ToString("o", CultureInfo.InvariantCulture),
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ internal static readonly (string[] Metrics, AuthoredTemplateEntry Entry)[] s_aut
new AuthoredTemplateEntry("authored/agent-job", AgentJobTemplateVersion, BuildAgentJobCells)),
(new[] { "Forced Plan Failing" },
new AuthoredTemplateEntry("authored/forced-plan-failing", ForcedPlanFailingTemplateVersion, BuildForcedPlanFailingCells)),
(new[] { "High CPU" },
new AuthoredTemplateEntry("authored/cpu", CpuTemplateVersion, BuildCpuCells)),
(new[] { "Long-Running Query" },
new AuthoredTemplateEntry("authored/long-running-query", LongRunningQueryTemplateVersion, BuildLongRunningQueryCells)),
(new[] { "PostgreSQL Replication Slot Retention" },
Expand Down Expand Up @@ -429,6 +431,12 @@ internal static readonly (string[] Metrics, AuthoredTemplateEntry Entry)[] s_aut
internal static readonly IReadOnlySet<string> s_authoredLimitlessTrendReads = new HashSet<string>(StringComparer.Ordinal)
{
"get_deadlock_trend",
/* #4223: get_top_queries_by_cpu / get_top_procedures_by_cpu / get_cpu_scheduler_pressure declare no
'limit' param at all -- the first two cap with 'top' (carried explicitly below), the last has no
row cap to carry. */
"get_top_queries_by_cpu",
"get_top_procedures_by_cpu",
"get_cpu_scheduler_pressure",
/* #4223: get_collection_health and get_running_jobs declare only PServer() — no hours/limit/as_of at
all — so ServerOnlyReadCell never adds a limit param and this exemption applies here too, even
though neither is a trend read. */
Expand Down
Loading