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
37 changes: 34 additions & 3 deletions CHANGELOG.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Dashboard/Dashboard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
<StartupObject>PerformanceMonitorDashboard.Program</StartupObject>
<AssemblyName>PerformanceMonitorDashboard</AssemblyName>
<Product>SQL Server Performance Monitor Dashboard</Product>
<Version>2.11.0</Version>
<AssemblyVersion>2.11.0.0</AssemblyVersion>
<FileVersion>2.11.0.0</FileVersion>
<InformationalVersion>2.11.0</InformationalVersion>
<Version>3.0.0</Version>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>
<InformationalVersion>3.0.0</InformationalVersion>
<Company>Darling Data, LLC</Company>
<Copyright>Copyright © 2026 Darling Data, LLC</Copyright>
<ApplicationIcon>EDD.ico</ApplicationIcon>
Expand Down
15 changes: 14 additions & 1 deletion Dashboard/Services/DatabaseService.FinOps.Inventory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,20 @@ IF @on_pos > 0

SELECT
edition =
CONVERT(nvarchar(256), SERVERPROPERTY('Edition')),
/* Azure SQL DB reports the legacy 'SQL Azure' for SERVERPROPERTY('Edition');
show the actual product name + service tier instead. Mirrors Lite's
LocalDataService.FinOps.ServerProperties. */
CASE
WHEN CONVERT(int, SERVERPROPERTY('EngineEdition')) = 5
THEN N'Azure SQL Database'
+ ISNULL(N' (' +
CASE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), 'Edition'))
WHEN N'GeneralPurpose' THEN N'General Purpose'
WHEN N'BusinessCritical' THEN N'Business Critical'
ELSE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), 'Edition'))
END + N')', N'')
ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition'))
END,
product_version =
CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')),
product_level =
Expand Down
8 changes: 4 additions & 4 deletions Installer.Core/Installer.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
<RootNamespace>Installer.Core</RootNamespace>
<AssemblyName>Installer.Core</AssemblyName>
<Product>SQL Server Performance Monitor Installer Core</Product>
<Version>2.11.0</Version>
<AssemblyVersion>2.11.0.0</AssemblyVersion>
<FileVersion>2.11.0.0</FileVersion>
<InformationalVersion>2.11.0</InformationalVersion>
<Version>3.0.0</Version>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>
<InformationalVersion>3.0.0</InformationalVersion>
<Company>Darling Data, LLC</Company>
<Copyright>Copyright (c) 2026 Darling Data, LLC</Copyright>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
Expand Down
8 changes: 4 additions & 4 deletions Installer/PerformanceMonitorInstaller.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
<!-- Application metadata -->
<AssemblyName>PerformanceMonitorInstaller</AssemblyName>
<Product>SQL Server Performance Monitor Installer</Product>
<Version>2.11.0</Version>
<AssemblyVersion>2.11.0.0</AssemblyVersion>
<FileVersion>2.11.0.0</FileVersion>
<InformationalVersion>2.11.0</InformationalVersion>
<Version>3.0.0</Version>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>
<InformationalVersion>3.0.0</InformationalVersion>
<Company>Darling Data, LLC</Company>
<Copyright>Copyright © 2026 Darling Data, LLC</Copyright>
<Description>Installation utility for SQL Server Performance Monitor - Supports SQL Server 2016-2025</Description>
Expand Down
8 changes: 4 additions & 4 deletions Lite/PerformanceMonitorLite.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
<AssemblyName>PerformanceMonitorLite</AssemblyName>
<RootNamespace>PerformanceMonitorLite</RootNamespace>
<Product>SQL Server Performance Monitor Lite</Product>
<Version>2.11.0</Version>
<AssemblyVersion>2.11.0.0</AssemblyVersion>
<FileVersion>2.11.0.0</FileVersion>
<InformationalVersion>2.11.0</InformationalVersion>
<Version>3.0.0</Version>
<AssemblyVersion>3.0.0.0</AssemblyVersion>
<FileVersion>3.0.0.0</FileVersion>
<InformationalVersion>3.0.0</InformationalVersion>
<Company>Darling Data, LLC</Company>
<Copyright>Copyright © 2026 Darling Data, LLC</Copyright>
<Description>Lightweight SQL Server performance monitoring - no installation required on target servers</Description>
Expand Down
15 changes: 14 additions & 1 deletion Lite/Services/LocalDataService.FinOps.ServerProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,20 @@ FROM sys.dm_hadr_availability_replica_states AS ars
END;

SELECT
CONVERT(nvarchar(256), SERVERPROPERTY('Edition')),
/* Azure SQL DB reports the legacy 'SQL Azure' for SERVERPROPERTY('Edition');
show the actual product name + service tier (e.g. 'Azure SQL Database
(General Purpose)') instead. */
CASE
WHEN CONVERT(int, SERVERPROPERTY('EngineEdition')) = 5
THEN N'Azure SQL Database'
+ ISNULL(N' (' +
CASE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), 'Edition'))
WHEN N'GeneralPurpose' THEN N'General Purpose'
WHEN N'BusinessCritical' THEN N'Business Critical'
ELSE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), 'Edition'))
END + N')', N'')
ELSE CONVERT(nvarchar(256), SERVERPROPERTY('Edition'))
END,
CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')),
CONVERT(nvarchar(128), SERVERPROPERTY('ProductLevel')),
CONVERT(nvarchar(128), SERVERPROPERTY('ProductUpdateLevel')),
Expand Down
14 changes: 13 additions & 1 deletion Lite/Services/RemoteCollectorService.ServerProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,19 @@ private async Task<int> CollectServerPropertiesAsync(ServerConnection server, Ca
server_name =
CONVERT(nvarchar(128), SERVERPROPERTY(N'ServerName')),
edition =
CONVERT(nvarchar(128), SERVERPROPERTY(N'Edition')),
/* Azure SQL DB reports the legacy 'SQL Azure' for SERVERPROPERTY('Edition');
store the actual product name + service tier instead. */
CASE
WHEN CONVERT(int, SERVERPROPERTY(N'EngineEdition')) = 5
THEN N'Azure SQL Database'
+ ISNULL(N' (' +
CASE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'Edition'))
WHEN N'GeneralPurpose' THEN N'General Purpose'
WHEN N'BusinessCritical' THEN N'Business Critical'
ELSE CONVERT(nvarchar(128), DATABASEPROPERTYEX(DB_NAME(), N'Edition'))
END + N')', N'')
ELSE CONVERT(nvarchar(128), SERVERPROPERTY(N'Edition'))
END,
product_version =
CONVERT(nvarchar(128), SERVERPROPERTY(N'ProductVersion')),
product_level =
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ All release binaries are digitally signed via [SignPath](https://signpath.io)

## What You Get

🔍 **32 specialized T-SQL collectors** running on configurable schedules with named presets (Off, Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments. Switch presets with a pair of SQL Agent jobs to get quiet-hours / overnight windows without writing any code.
🔍 **33 specialized T-SQL collectors** running on configurable schedules with named presets (Off, Aggressive, Balanced, Low-Impact) — wait stats, query performance, blocking chains, deadlock graphs, memory grants, file I/O, tempdb, perfmon counters, FinOps/capacity, and more. Query text and execution plan collection can be disabled per-collector for sensitive environments. Switch presets with a pair of SQL Agent jobs to get quiet-hours / overnight windows without writing any code.

🚨 **Real-time alerts** for blocking, deadlocks, and high CPU — system tray notifications, styled HTML emails with full XML attachments, and webhook notifications for external integrations

Expand Down Expand Up @@ -104,7 +104,7 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser

### Lite Collectors

24 collectors run on independent, configurable schedules:
25 collectors run on independent, configurable schedules:

| Collector | Default | Source |
|---|---|---|
Expand All @@ -128,6 +128,7 @@ Data starts flowing within 1–5 minutes. That's it. No installation on your ser
| running_jobs | 5 min | `msdb` job history with duration vs avg/p95 |
| database_size_stats | 15 min | `sys.master_files` + `FILEPROPERTY` + `dm_os_volume_stats` |
| server_properties | 15 min | `SERVERPROPERTY()` hardware and licensing metadata |
| index_object_stats | Daily | `sys.dm_db_partition_stats` + `sys.dm_db_index_usage_stats` + `sys.dm_db_index_operational_stats` |
| server_config | On connect | `sys.configurations` |
| database_config | On connect | `sys.databases` |
| database_scoped_config | On connect | Database-scoped configurations |
Expand Down Expand Up @@ -254,7 +255,7 @@ ORDER BY collection_time DESC;
### What Gets Installed

- **PerformanceMonitor database** with collection tables and reporting views
- **32 collector stored procedures** for gathering metrics (including SQL Agent job monitoring)
- **33 collector stored procedures** for gathering metrics (including SQL Agent job monitoring)
- **Configurable collection** — query text and execution plan capture can be disabled per-collector via `config.collection_schedule` (`collect_query`, `collect_plan` columns) for sensitive or high-volume environments
- **Delta framework** for calculating per-second rates from cumulative DMVs
- **Community dependencies:** sp_WhoIsActive, sp_HealthParser, sp_HumanEventsBlockViewer, sp_BlitzLock
Expand Down Expand Up @@ -369,7 +370,7 @@ Plus a NOC-style landing page with server health cards (green/yellow/red severit
| **Blocking** | Blocking/deadlock trends, blocked process reports, deadlock history |
| **Perfmon** | Selectable SQL Server performance counters over time |
| **Configuration** | Server configuration, database configuration, scoped configuration, trace flags |
| **FinOps** | Utilization & provisioning analysis, database resource breakdown, storage growth (7d/30d), idle database detection, index analysis via sp_IndexCleanup, application connections, server inventory, cost optimization recommendations (enterprise feature audit, CPU/memory right-sizing, compression savings, dormant databases, dev/test detection), column-level filtering on all grids |
| **FinOps** | Utilization & provisioning analysis, database resource breakdown, storage growth (7d/30d), idle database detection, index analysis via sp_IndexCleanup, per-object table/index size, growth, usage, and locking/contention analysis, application connections, server inventory, cost optimization recommendations (enterprise feature audit, CPU/memory right-sizing, compression savings, dormant databases, dev/test detection), column-level filtering on all grids |

Both editions feature auto-refresh, configurable time ranges, chart drill-down to Active Queries, right-click CSV export, system tray integration, dark and light themes, and timezone display options (server time, local time, or UTC).

Expand Down
92 changes: 59 additions & 33 deletions install/23_process_blocked_process_xml.sql
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,20 @@ BEGIN
The proc expects local time inputs and converts to UTC internally
Raw table event_time is UTC (from XE @timestamp attribute)
*/
/*
Pad the upper bound by one second. sp_HumanEventsBlockViewer
filters the source table with a half-open window
(event_time < @end_date), so without the pad the newest
event(s) - and an entire batch sharing a single timestamp
(MIN = MAX, the common case because a blocked-process monitor
loop emits every report at one instant) - fall outside
[MIN, MAX) and are never parsed. The local/UTC basis itself
round-trips correctly: this proc shifts UTC event_time to local
and sp_HumanEventsBlockViewer shifts it back to UTC internally.
*/
SELECT
@start_date_local = DATEADD(MINUTE, @utc_offset_minutes, @start_date),
@end_date_local = DATEADD(MINUTE, @utc_offset_minutes, @end_date);
@end_date_local = DATEADD(SECOND, 1, DATEADD(MINUTE, @utc_offset_minutes, @end_date));

IF @debug = 1
BEGIN
Expand Down Expand Up @@ -357,31 +368,46 @@ BEGIN
);
END;

/*
Mark raw XML rows as processed
Only mark the rows in the date range we just processed
*/
UPDATE bx
SET bx.is_processed = 1
FROM collect.blocked_process_xml AS bx
WHERE bx.is_processed = 0
AND (@start_date IS NULL OR bx.event_time >= @start_date)
AND (@end_date IS NULL OR bx.event_time <= @end_date);

SELECT
@rows_marked = ROWCOUNT_BIG();
END;

IF @debug = 1
BEGIN
RAISERROR(N'Marked %I64d raw XML rows as processed (%I64d parsed blocking events)', 0, 1, @rows_marked, @rows_parsed) WITH NOWAIT;
END;
/*
Mark the raw XML rows we handed to sp_HumanEventsBlockViewer as
processed - UNCONDITIONALLY after a clean parse run, not only when
@rows_parsed > 0. The viewer legitimately returns zero rows for
events that carry no lock-blocking chain between distinct sessions:
self-blocks, and non-lock GENERIC/NL waits such as a memory-grant
RESOURCE_SEMAPHORE wait that tripped blocked_process_threshold.
Those never parse, so gating the mark on @rows_parsed > 0 left them
unprocessed forever - the processor re-ran the viewer over the same
dead events every cycle and re-logged NO_RESULTS indefinitely.

Safe because the upper bound was padded (+1s) above, so the viewer's
half-open window actually covers every unprocessed event - we never
mark a row the viewer did not get to see. Genuine failures never
reach here either: the XACT_STATE() = -1 check and the CATCH block
both roll back without marking, so a real parse failure still
retries next run. Raw XML is retained (is_processed = 1, not
deleted); data-retention handles cleanup. event_time is UTC,
matching @start_date / @end_date.
*/
IF @rows_parsed = 0 AND @debug = 1
BEGIN
RAISERROR(N'sp_HumanEventsBlockViewer produced 0 parsed results for %d XML event(s) - no lock-blocking chains (self-block / non-lock waits); events still marked processed', 0, 1, @rows_available) WITH NOWAIT;
END;
ELSE

UPDATE bx
SET bx.is_processed = 1
FROM collect.blocked_process_xml AS bx
WHERE bx.is_processed = 0
AND (@start_date IS NULL OR bx.event_time >= @start_date)
AND (@end_date IS NULL OR bx.event_time <= @end_date);

SELECT
@rows_marked = ROWCOUNT_BIG();

IF @debug = 1
BEGIN
IF @debug = 1
BEGIN
RAISERROR(N'sp_HumanEventsBlockViewer produced 0 parsed results for %d XML events - rows left unprocessed for retry', 0, 1, @rows_available) WITH NOWAIT;
END;
RAISERROR(N'Marked %I64d raw XML rows as processed (%I64d parsed blocking events)', 0, 1, @rows_marked, @rows_parsed) WITH NOWAIT;
END;
END;

Expand All @@ -400,18 +426,18 @@ BEGIN
VALUES
(
N'process_blocked_process_xml',
CASE WHEN @rows_available = 0 THEN N'SUCCESS'
WHEN @rows_parsed > 0 THEN N'SUCCESS'
ELSE N'NO_RESULTS'
END,
/*
A clean parse run is SUCCESS even when it produced 0 blocking
chains: the events were processed and marked, they simply carried
no lock-blocking between distinct sessions (self-block / non-lock
waits). Genuine failures take the CATCH path and log ERROR. This
ends the perpetual NO_RESULTS this collector used to emit for
un-parseable-by-design events.
*/
N'SUCCESS',
@rows_available,
DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
CASE WHEN @rows_available > 0 AND @rows_parsed = 0
THEN N'sp_HumanEventsBlockViewer returned 0 parsed results for '
+ CAST(@rows_available AS nvarchar(20))
+ N' XML events - rows left unprocessed for retry'
ELSE NULL
END
NULL
);

IF @debug = 1
Expand Down
54 changes: 31 additions & 23 deletions install/25_process_deadlock_xml.sql
Original file line number Diff line number Diff line change
Expand Up @@ -232,33 +232,41 @@ BEGIN
AND d.event_date <= @end_date_local
OPTION(RECOMPILE);

IF @rows_parsed > 0
/*
Mark the raw XML rows we handed to sp_BlitzLock as processed -
UNCONDITIONALLY after a clean parse run, not only when
@rows_parsed > 0. sp_BlitzLock legitimately returns zero rows for
deadlock graphs it cannot parse (malformed/partial graphs, or
non-deadlock events captured by the session). Gating the mark on
@rows_parsed > 0 left those unprocessed forever - the processor
re-ran sp_BlitzLock over the same dead events every cycle and
re-logged NO_RESULTS indefinitely. Genuine failures never reach
here: the XACT_STATE() = -1 check and the CATCH block both roll back
without marking, so a real parse failure still retries next run. Raw
XML is retained (is_processed = 1, not deleted); data-retention
handles cleanup. The +1s pad on @end_date above guarantees the parse
window covers every unprocessed event, so we never mark a row
sp_BlitzLock did not get to see. event_time is UTC, matching
@start_date / @end_date.
*/
IF @rows_parsed = 0 AND @debug = 1
BEGIN
/*
Mark raw XML rows as processed
Only mark the rows in the date range we just processed
*/
UPDATE dx
SET dx.is_processed = 1
FROM collect.deadlock_xml AS dx
WHERE dx.is_processed = 0
AND (@start_date IS NULL OR dx.event_time >= @start_date)
AND (@end_date IS NULL OR dx.event_time <= @end_date);
RAISERROR(N'sp_BlitzLock produced 0 parsed results for %d XML event(s) - no parseable deadlock graphs; events still marked processed', 0, 1, @rows_available) WITH NOWAIT;
END;

SELECT
@rows_marked = ROWCOUNT_BIG();
UPDATE dx
SET dx.is_processed = 1
FROM collect.deadlock_xml AS dx
WHERE dx.is_processed = 0
AND (@start_date IS NULL OR dx.event_time >= @start_date)
AND (@end_date IS NULL OR dx.event_time <= @end_date);

IF @debug = 1
BEGIN
RAISERROR(N'Marked %I64d raw XML rows as processed (%I64d parsed deadlocks)', 0, 1, @rows_marked, @rows_parsed) WITH NOWAIT;
END;
END;
ELSE
SELECT
@rows_marked = ROWCOUNT_BIG();

IF @debug = 1
BEGIN
IF @debug = 1
BEGIN
RAISERROR(N'sp_BlitzLock produced 0 parsed results for %d XML events - rows left unprocessed for retry', 0, 1, @rows_available) WITH NOWAIT;
END;
RAISERROR(N'Marked %I64d raw XML rows as processed (%I64d parsed deadlocks)', 0, 1, @rows_marked, @rows_parsed) WITH NOWAIT;
END;
END;

Expand Down
Loading
Loading