Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2213d86
Added exclusions in GetLongRunningQueriesAsync() method for SP_SERVER…
HannahVernon Feb 27, 2026
9b249fc
Added TOP parameter for query in GetLongRunningQueriesAsync() method …
HannahVernon Feb 27, 2026
adefd25
Replaced waitForFilter string constructor with C# string interpolatio…
HannahVernon Feb 27, 2026
481a3a6
Merge pull request #2 from HannahVernon/feature/long-running-query-ex…
HannahVernon Feb 27, 2026
f75bb2d
Added exclusion for backup-related waits to GetLongRunningQueriesAsyn…
HannahVernon Feb 27, 2026
9c2c5f2
Merge pull request #4 from HannahVernon/feature/add-exclusion-for-BAC…
HannahVernon Feb 27, 2026
fd9b497
Reverted Controls/LandingPage.xaml.cs
HannahVernon Mar 2, 2026
a98ef7b
Added BROKER_RECEIVE_WAITFOR wait type to waitforFilter exclusions. …
HannahVernon Mar 2, 2026
b6d3d3f
Merge pull request #5 from HannahVernon/feature/add-exclusion-for-BAC…
HannahVernon Mar 2, 2026
ab1c47d
Added filtering for SP_SERVER_DIAGNOSTICS, WAITFOR, BROKER_RECEIVE_WA…
HannahVernon Mar 2, 2026
a061f99
Merge pull request #6 from HannahVernon/feature/sync-long-running-que…
HannahVernon Mar 2, 2026
4962abd
Merge branch 'erikdarlingdata:dev' into dev
HannahVernon Mar 2, 2026
8ac4903
Add configurable max results setting for long-running queries
HannahVernon Mar 3, 2026
c5476dd
Apply max results validation and Lite parity for long-running queries
HannahVernon Mar 3, 2026
b121b4a
Use GetInt64() when loading long-running query max results from JSON
HannahVernon Mar 3, 2026
1ceb065
Add configurable long-running query filter toggles
HannahVernon Mar 3, 2026
e10771e
Merge pull request #7 from HannahVernon/feature/long-running-queries-…
HannahVernon Mar 3, 2026
2f54765
merged with incoming dev branch
HannahVernon Mar 3, 2026
f8998d1
Merged with incoming dev branch.
HannahVernon Mar 4, 2026
4d55c32
Merge branch 'dev' into feature/long-running-queries-config-settings
HannahVernon Mar 4, 2026
d7a7e41
Merge pull request #8 from HannahVernon/feature/long-running-queries-…
HannahVernon Mar 4, 2026
0b06917
Add alert filter by database for Dashboard and Lite
HannahVernon Mar 4, 2026
0c6e320
Merged with incoming dev branch.
HannahVernon Mar 4, 2026
8eb6771
Restore excludedDatabases to GetAlertHealthAsync after merge
HannahVernon Mar 4, 2026
255cadb
Fix CA1859: use List<string>? for BuildDeadlockContextAsync parameter
HannahVernon Mar 4, 2026
f41d087
fix: filter excluded databases before deadlock alert threshold check
HannahVernon Mar 5, 2026
5e41b5d
Fix Lite missing deadlock and blocking exclusion filtering
HannahVernon Mar 5, 2026
746591a
Restore 1000 cap on alert_long_running_query_max_results clamp
HannahVernon Mar 5, 2026
781069f
Update excluded databases help text to mention deadlock alerts
HannahVernon Mar 5, 2026
4e2a4a8
Fix GetFilteredDeadlockCountAsync catch block returning 0 instead of …
HannahVernon Mar 5, 2026
648ac16
Use parameterized queries in GetBlockingValuesAsync and GetFilteredDe…
HannahVernon Mar 5, 2026
b1ca51b
Move database exclusions to Global Alert Filters section in Settings UI
HannahVernon Mar 5, 2026
988a6dc
Fix incorrect column reference in GetFilteredDeadlockCountAsync
HannahVernon Mar 5, 2026
a5decf8
Use XML-based deadlock filtering in Dashboard for cross-database accu…
HannahVernon Mar 5, 2026
19855c7
Filter excluded databases in Dashboard BuildBlockingContextAsync emai…
HannahVernon Mar 5, 2026
58c6c3e
Clear excluded databases on Restore Defaults in both editions
HannahVernon Mar 5, 2026
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
80 changes: 67 additions & 13 deletions Dashboard/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
using PerformanceMonitorDashboard.Services;
using System.ComponentModel;
using System.Windows.Data;
using System.Xml.Linq;

namespace PerformanceMonitorDashboard
{
Expand Down Expand Up @@ -1065,7 +1066,7 @@ private async Task CheckAllServerAlertsAsync()
var connectionString = server.GetConnectionString(_credentialService);
var databaseService = new DatabaseService(connectionString);
var connStatus = _serverManager.GetConnectionStatus(server.Id);
var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier, prefs.LongRunningQueryMaxResults, prefs.LongRunningQueryExcludeSpServerDiagnostics, prefs.LongRunningQueryExcludeWaitFor, prefs.LongRunningQueryExcludeBackups, prefs.LongRunningQueryExcludeMiscWaits);
var health = await databaseService.GetAlertHealthAsync(connStatus.SqlEngineEdition, prefs.LongRunningQueryThresholdMinutes, prefs.LongRunningJobMultiplier, prefs.LongRunningQueryMaxResults, prefs.LongRunningQueryExcludeSpServerDiagnostics, prefs.LongRunningQueryExcludeWaitFor, prefs.LongRunningQueryExcludeBackups, prefs.LongRunningQueryExcludeMiscWaits, prefs.AlertExcludedDatabases);

if (health.IsOnline)
{
Expand Down Expand Up @@ -1124,7 +1125,7 @@ private async Task EvaluateAlertConditionsAsync(
$"{(int)health.TotalBlocked} session(s), longest {(int)health.LongestBlockedSeconds}s",
$"{prefs.BlockingThresholdSeconds}s", true, "tray");

var blockingContext = await BuildBlockingContextAsync(databaseService);
var blockingContext = await BuildBlockingContextAsync(databaseService, prefs.AlertExcludedDatabases);

await _emailAlertService.TrySendAlertEmailAsync(
"Blocking Detected",
Expand Down Expand Up @@ -1152,8 +1153,13 @@ await _emailAlertService.TrySendAlertEmailAsync(
}
_previousDeadlockCounts[serverId] = health.DeadlockCount;

/* Use the database-filtered count when excluded databases are configured,
matching how blocking alerts filter before the threshold check.
Falls back to the raw delta when no databases are excluded. */
var effectiveDeadlockDelta = health.FilteredDeadlockCount ?? deadlockDelta;

bool deadlocksExceeded = prefs.NotifyOnDeadlock
&& deadlockDelta >= prefs.DeadlockThreshold;
&& effectiveDeadlockDelta >= prefs.DeadlockThreshold;

if (deadlocksExceeded)
{
Expand All @@ -1162,19 +1168,19 @@ await _emailAlertService.TrySendAlertEmailAsync(
{
_notificationService?.ShowDeadlockNotification(
serverName,
(int)deadlockDelta);
(int)effectiveDeadlockDelta);
_lastDeadlockAlert[serverId] = now;

_emailAlertService.RecordAlert(serverId, serverName, "Deadlocks Detected",
deadlockDelta.ToString(),
effectiveDeadlockDelta.ToString(),
prefs.DeadlockThreshold.ToString(), true, "tray");

var deadlockContext = await BuildDeadlockContextAsync(databaseService);
var deadlockContext = await BuildDeadlockContextAsync(databaseService, prefs.AlertExcludedDatabases);

await _emailAlertService.TrySendAlertEmailAsync(
"Deadlocks Detected",
serverName,
deadlockDelta.ToString(),
effectiveDeadlockDelta.ToString(),
prefs.DeadlockThreshold.ToString(),
serverId,
deadlockContext);
Expand Down Expand Up @@ -1264,15 +1270,23 @@ await _emailAlertService.TrySendAlertEmailAsync(
}

/* Long-running query alerts */
var lrqList = health.LongRunningQueries;
if (prefs.AlertExcludedDatabases.Count > 0)
lrqList = lrqList
.Where(q => string.IsNullOrEmpty(q.DatabaseName) ||
!prefs.AlertExcludedDatabases.Any(e =>
string.Equals(e, q.DatabaseName, StringComparison.OrdinalIgnoreCase)))
.ToList();

bool longRunningTriggered = prefs.NotifyOnLongRunningQueries
&& health.LongRunningQueries.Count > 0;
&& lrqList.Count > 0;

if (longRunningTriggered)
{
_activeLongRunningQueryAlert[serverId] = true;
if (!_lastLongRunningQueryAlert.TryGetValue(serverId, out var lastAlert) || (now - lastAlert) >= AlertCooldown)
{
var worst = health.LongRunningQueries[0];
var worst = lrqList[0];
var elapsedMinutes = worst.ElapsedSeconds / 60;
var preview = Truncate(worst.QueryText, 80);
_notificationService?.ShowLongRunningQueryNotification(
Expand All @@ -1283,12 +1297,12 @@ await _emailAlertService.TrySendAlertEmailAsync(
$"Session #{worst.SessionId} running {elapsedMinutes}m",
$"{prefs.LongRunningQueryThresholdMinutes}m", true, "tray");

var lrqContext = BuildLongRunningQueryContext(health.LongRunningQueries);
var lrqContext = BuildLongRunningQueryContext(lrqList);

await _emailAlertService.TrySendAlertEmailAsync(
"Long-Running Query",
serverName,
$"{health.LongRunningQueries.Count} query(s), longest {elapsedMinutes}m",
$"{lrqList.Count} query(s), longest {elapsedMinutes}m",
$"{prefs.LongRunningQueryThresholdMinutes}m",
serverId,
lrqContext);
Expand Down Expand Up @@ -1388,13 +1402,23 @@ private static string Truncate(string text, int maxLength = 300)
return text.Length <= maxLength ? text : text.Substring(0, maxLength) + "...";
}

private static async Task<AlertContext?> BuildBlockingContextAsync(DatabaseService databaseService)
private static async Task<AlertContext?> BuildBlockingContextAsync(DatabaseService databaseService, List<string>? excludedDatabases = null)
{
try
{
var events = await databaseService.GetBlockingEventsAsync(hoursBack: 1);
if (events == null || events.Count == 0) return null;

if (excludedDatabases != null && excludedDatabases.Count > 0)
{
events = events
.Where(e => string.IsNullOrEmpty(e.DatabaseName) ||
!excludedDatabases.Any(ex =>
string.Equals(ex, e.DatabaseName, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (events.Count == 0) return null;
}

var context = new AlertContext();
var firstXml = (string?)null;

Expand Down Expand Up @@ -1436,13 +1460,20 @@ private static string Truncate(string text, int maxLength = 300)
}
}

private static async Task<AlertContext?> BuildDeadlockContextAsync(DatabaseService databaseService)
private static async Task<AlertContext?> BuildDeadlockContextAsync(DatabaseService databaseService, List<string>? excludedDatabases = null)
{
try
{
var deadlocks = await databaseService.GetDeadlocksAsync(hoursBack: 1);
if (deadlocks == null || deadlocks.Count == 0) return null;

if (excludedDatabases != null && excludedDatabases.Count > 0)
{
deadlocks = deadlocks
.Where(d => !IsDeadlockExcluded(d, excludedDatabases))
.ToList();
}

var context = new AlertContext();
var firstGraph = (string?)null;

Expand Down Expand Up @@ -1496,6 +1527,29 @@ private static string Truncate(string text, int maxLength = 300)
}
}

/// <summary>
/// Returns true if a deadlock should be excluded based on the deadlock graph XML.
/// A deadlock is only excluded when ALL process nodes have a currentdbname in the excluded list.
/// Cross-database deadlocks involving any non-excluded database will still be reported.
/// </summary>
private static bool IsDeadlockExcluded(DeadlockItem deadlock, List<string> excludedDatabases)
{
if (string.IsNullOrEmpty(deadlock.DeadlockGraph)) return false;
try
{
var doc = XElement.Parse(deadlock.DeadlockGraph);
var dbNames = doc.Descendants("process")
.Select(p => p.Attribute("currentdbname")?.Value)
.Where(n => !string.IsNullOrEmpty(n))
.Cast<string>()
.ToList();
if (dbNames.Count == 0) return false;
return dbNames.All(db => excludedDatabases.Any(e =>
string.Equals(e, db, StringComparison.OrdinalIgnoreCase)));
}
catch { return false; }
}

private static AlertContext? BuildPoisonWaitContext(List<PoisonWaitDelta> triggeredWaits)
{
if (triggeredWaits.Count == 0) return null;
Expand Down
9 changes: 9 additions & 0 deletions Dashboard/Models/AlertHealthResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ public class AlertHealthResult
public long TotalBlocked { get; set; }
public decimal LongestBlockedSeconds { get; set; }
public long DeadlockCount { get; set; }

/// <summary>
/// Deadlock count for the alert window filtered by excluded databases.
/// Sourced from collect.blocking_deadlock_stats when excluded databases are configured.
/// When set, EvaluateAlertConditionsAsync uses this instead of the raw delta
/// from the server-wide performance counter, matching how blocking alerts filter.
/// Null when no databases are excluded (fall back to raw delta).
/// </summary>
public long? FilteredDeadlockCount { get; set; }
public List<PoisonWaitDelta> PoisonWaits { get; set; } = new();
public List<LongRunningQueryInfo> LongRunningQueries { get; set; } = new();
public TempDbSpaceInfo? TempDbSpace { get; set; }
Expand Down
3 changes: 3 additions & 0 deletions Dashboard/Models/UserPreferences.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ private static string GetDefaultCsvSeparator()
// Update check settings
public bool CheckForUpdatesOnStartup { get; set; } = true;

// Alert database exclusions
public List<string> AlertExcludedDatabases { get; set; } = new();

// Alert suppression (persisted)
public List<string> SilencedServers { get; set; } = new();
public List<string> SilencedServerTabs { get; set; } = new();
Expand Down
73 changes: 68 additions & 5 deletions Dashboard/Services/DatabaseService.NocHealth.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using PerformanceMonitorDashboard.Helpers;
Expand Down Expand Up @@ -129,7 +130,8 @@ public async Task<AlertHealthResult> GetAlertHealthAsync(
bool excludeSpServerDiagnostics = true,
bool excludeWaitFor = true,
bool excludeBackups = true,
bool excludeMiscWaits = true)
bool excludeMiscWaits = true,
IReadOnlyList<string>? excludedDatabases = null)
{
var result = new AlertHealthResult();

Expand All @@ -141,14 +143,20 @@ public async Task<AlertHealthResult> GetAlertHealthAsync(
result.IsOnline = true;

var cpuTask = GetCpuPercentAsync(connection, engineEdition);
var blockingTask = GetBlockingValuesAsync(connection);
var blockingTask = GetBlockingValuesAsync(connection, excludedDatabases ?? Array.Empty<string>());
var deadlockTask = GetDeadlockCountAsync(connection);
var filteredDeadlockTask = excludedDatabases?.Count > 0
? GetFilteredDeadlockCountAsync(connection, excludedDatabases)
: null;
var poisonWaitTask = GetPoisonWaitDeltasAsync(connection);
var longRunningTask = GetLongRunningQueriesAsync(connection, longRunningQueryThresholdMinutes, longRunningQueryMaxResults, excludeSpServerDiagnostics, excludeWaitFor, excludeBackups, excludeMiscWaits);
var tempDbTask = GetTempDbSpaceAsync(connection);
var anomalousJobTask = GetAnomalousJobsAsync(connection, longRunningJobMultiplier);

await Task.WhenAll(cpuTask, blockingTask, deadlockTask, poisonWaitTask, longRunningTask, tempDbTask, anomalousJobTask);
var allTasks = filteredDeadlockTask != null
? new Task[] { cpuTask, blockingTask, deadlockTask, filteredDeadlockTask, poisonWaitTask, longRunningTask, tempDbTask, anomalousJobTask }
: new Task[] { cpuTask, blockingTask, deadlockTask, poisonWaitTask, longRunningTask, tempDbTask, anomalousJobTask };
await Task.WhenAll(allTasks);

var cpuResult = await cpuTask;
result.CpuPercent = cpuResult.SqlCpu;
Expand All @@ -159,6 +167,8 @@ public async Task<AlertHealthResult> GetAlertHealthAsync(
result.LongestBlockedSeconds = blockingResult.LongestBlockedSeconds;

result.DeadlockCount = await deadlockTask;
if (filteredDeadlockTask != null)
result.FilteredDeadlockCount = await filteredDeadlockTask;
result.PoisonWaits = await poisonWaitTask;
result.LongRunningQueries = await longRunningTask;
result.TempDbSpace = await tempDbTask;
Expand All @@ -177,22 +187,32 @@ public async Task<AlertHealthResult> GetAlertHealthAsync(
/// Returns blocking values directly (without writing to a ServerHealthStatus).
/// Used by GetAlertHealthAsync for lightweight alert checks.
/// </summary>
private async Task<(long TotalBlocked, decimal LongestBlockedSeconds)> GetBlockingValuesAsync(SqlConnection connection)
private async Task<(long TotalBlocked, decimal LongestBlockedSeconds)> GetBlockingValuesAsync(SqlConnection connection, IReadOnlyList<string> excludedDatabases)
{
const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
var dbFilter = "";
var dbParams = new List<string>();
for (int i = 0; i < excludedDatabases.Count; i++)
dbParams.Add($"@exdb{i}");
if (dbParams.Count > 0)
dbFilter = $"AND DB_NAME(s.dbid) NOT IN ({string.Join(", ", dbParams)})";

var query = $@"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

SELECT
total_blocked = COUNT_BIG(*),
longest_blocked_seconds = ISNULL(MAX(s.waittime), 0) / 1000.0
FROM sys.sysprocesses AS s
WHERE s.blocked <> 0
AND s.lastwaittype LIKE N'LCK%'
{dbFilter}
OPTION(MAXDOP 1, RECOMPILE);";

try
{
using var cmd = new SqlCommand(query, connection);
cmd.CommandTimeout = 10;
for (int i = 0; i < excludedDatabases.Count; i++)
cmd.Parameters.AddWithValue($"@exdb{i}", excludedDatabases[i]);
using var reader = await cmd.ExecuteReaderAsync();

if (await reader.ReadAsync())
Expand Down Expand Up @@ -431,6 +451,49 @@ WHERE pc.counter_name LIKE N'Number of Deadlocks/sec%'
}
}

/// <summary>
/// Counts recent deadlocks from collect.blocking_deadlock_stats, excluding the specified databases.
/// Uses a 5-minute window matching the alert cooldown so each cooldown period
/// reflects only deadlocks from non-excluded databases.
/// This is the filtered equivalent of GetDeadlockCountAsync, which reads from
/// sys.dm_os_performance_counters and cannot be filtered by database.
/// </summary>
private async Task<long?> GetFilteredDeadlockCountAsync(SqlConnection connection, IReadOnlyList<string> excludedDatabases)
{
var dbFilter = "";
var dbParams = new List<string>();
for (int i = 0; i < excludedDatabases.Count; i++)
dbParams.Add($"@exdb{i}");
if (dbParams.Count > 0)
dbFilter = $"AND bds.database_name NOT IN ({string.Join(", ", dbParams)})";

var query = $@"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

SELECT
filtered_deadlock_count =
COALESCE(SUM(bds.deadlock_count_delta), 0)
FROM collect.blocking_deadlock_stats AS bds
WHERE bds.collection_time >= DATEADD(MINUTE, -5, SYSUTCDATETIME())
AND bds.deadlock_count_delta IS NOT NULL
{dbFilter}
OPTION(MAXDOP 1, RECOMPILE);";

try
{
using var cmd = new SqlCommand(query, connection);
cmd.CommandTimeout = 10;
for (int i = 0; i < excludedDatabases.Count; i++)
cmd.Parameters.AddWithValue($"@exdb{i}", excludedDatabases[i]);
var result = await cmd.ExecuteScalarAsync();
return result is long l ? l : (result is int i2 ? (long)i2 : 0);
}
catch (Exception ex)
{
Logger.Warning($"Failed to get filtered deadlock count: {ex.Message}");
return null; // Fall back to raw delta
}
}

private async Task GetCollectorStatusAsync(SqlConnection connection, ServerHealthStatus status)
{
const string query = @"SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
Expand Down
6 changes: 6 additions & 0 deletions Dashboard/SettingsWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,12 @@
</StackPanel>
<TextBlock x:Name="AlertPreviewText" FontSize="11" FontStyle="Italic" Foreground="Gray"
TextWrapping="Wrap" Margin="0,12,0,0"/>
<!-- Global Alert Filters -->
<TextBlock Text="Global Alert Filters" FontWeight="Bold" FontSize="12" Margin="0,16,0,6"/>
<TextBlock Text="Exclude databases from alerts (comma-separated):" Margin="0,0,0,4"/>
<TextBox x:Name="AlertExcludedDatabasesTextBox" Margin="0,0,0,4"/>
<TextBlock Text="e.g. DBAtools, msdb, tempdb — applies to blocking, deadlock, and long-running query alerts"
FontSize="11" FontStyle="Italic" Foreground="Gray" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
Expand Down
Loading
Loading