From dbf2ecfd11e1695d4876a76a68f5baa7c3b3651b Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Wed, 15 Apr 2026 12:24:02 -0400 Subject: [PATCH 1/3] sp_BlitzFirst: add Top 5 server/login/app breakdown to High Number of Connections (#3903) When CheckID 49 fires, the Details column now includes three ranked sections (Top 5 Servers, Top 5 Logins, Top 5 Apps) showing connection counts plus the most-recent and oldest query finish ages for each. Operators can quickly spot whether connection pressure is concentrated in one app server or login vs. spread across the fleet. Implementation notes: * Loads sys.dm_exec_connections joined to sys.dm_exec_sessions into a table variable once, then aggregates it three ways so the DMVs are only read once. * Treats sys.dm_exec_sessions.last_request_end_time = 1900-01-01 (the sentinel for sessions that have never run a request) as NULL so it renders as "unknown" rather than "45000 days ago". * Pre-computes the total connection count into a variable and gates the breakdown work behind an explicit IF so we only do the extra aggregation when the alert actually fires. * Bumps sp_BlitzFirst version to 8.33 / 20260415. Closes #3903. Co-Authored-By: Claude Opus 4.6 (1M context) --- sp_BlitzFirst.sql | 170 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 160 insertions(+), 10 deletions(-) diff --git a/sp_BlitzFirst.sql b/sp_BlitzFirst.sql index 7cc3f261..da863acd 100644 --- a/sp_BlitzFirst.sql +++ b/sp_BlitzFirst.sql @@ -47,7 +47,7 @@ SET NOCOUNT ON; SET STATISTICS XML OFF; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -SELECT @Version = '8.32', @VersionDate = '20260407'; +SELECT @Version = '8.33', @VersionDate = '20260415'; IF(@VersionCheckMode = 1) BEGIN @@ -2664,15 +2664,165 @@ If one of them is a lead blocker, consider killing that query.'' AS HowToStopit, IF @max_worker_threads > 0 BEGIN - INSERT INTO #BlitzFirstResults (CheckID, Priority, FindingsGroup, Finding, URL, Details) - SELECT 49 AS CheckID, - 210 AS Priority, - 'Potential Upcoming Problems' AS FindingGroup, - 'High Number of Connections' AS Finding, - 'https://www.brentozar.com/archive/2014/05/connections-slow-sql-server-threadpool/' AS URL, - 'There are ' + CAST(SUM(1) AS VARCHAR(20)) + ' open connections, which would lead to ' + @LineFeed + 'worker thread exhaustion and THREADPOOL waits' + @LineFeed + 'if they all ran queries at the same time.' AS Details - FROM sys.dm_exec_connections c - HAVING SUM(1) > @max_worker_threads; + /* Count connections first so we only build the Top 5 breakdowns when the + alert actually fires. See issue #3903 for the feature request. */ + DECLARE @TotalConnections INT; + SET @TotalConnections = (SELECT COUNT(*) FROM sys.dm_exec_connections); + + IF @TotalConnections > @max_worker_threads + BEGIN + /* Load connection + session attributes into a table variable once so we can + aggregate it three different ways (by host, login, and app) without + re-reading the DMVs. sys.dm_exec_sessions.last_request_end_time defaults + to 1900-01-01 for sessions that have never run a request - treat that + sentinel as NULL so it renders as "unknown" rather than "45000 days ago". */ + DECLARE @ConnSessions TABLE + ( + HostName NVARCHAR(128) NOT NULL, + LoginName NVARCHAR(128) NOT NULL, + AppName NVARCHAR(128) NOT NULL, + LastFinish DATETIME NULL + ); + + INSERT INTO @ConnSessions (HostName, LoginName, AppName, LastFinish) + SELECT + COALESCE(NULLIF(s.host_name, N''), N'(unknown host)'), + COALESCE(NULLIF(s.login_name, N''), N'(unknown login)'), + COALESCE(NULLIF(s.program_name, N''), N'(unknown app)'), + NULLIF(s.last_request_end_time, CONVERT(DATETIME, '1900-01-01')) + FROM sys.dm_exec_connections c + LEFT JOIN sys.dm_exec_sessions s ON s.session_id = c.session_id; + + DECLARE @TopServers NVARCHAR(MAX), + @TopLogins NVARCHAR(MAX), + @TopApps NVARCHAR(MAX); + + SELECT @TopServers = STUFF(( + SELECT @LineFeed + + g.ConnectionGroup + ' - ' + CAST(g.ConnectionCount AS VARCHAR(20)) + ' connections' + + ', most recent query finished ' + + CASE + WHEN g.MostRecentSec IS NULL THEN 'unknown' + WHEN g.MostRecentSec < 60 THEN CAST(g.MostRecentSec AS VARCHAR(10)) + ' seconds ago' + WHEN g.MostRecentSec < 3600 THEN CAST(g.MostRecentSec / 60 AS VARCHAR(10)) + ' minutes ago' + WHEN g.MostRecentSec < 86400 THEN CAST(g.MostRecentSec / 3600 AS VARCHAR(10)) + ' hours ' + + CAST((g.MostRecentSec % 3600) / 60 AS VARCHAR(10)) + ' minutes ago' + ELSE CAST(g.MostRecentSec / 86400 AS VARCHAR(10)) + ' days ' + + CAST((g.MostRecentSec % 86400) / 3600 AS VARCHAR(10)) + ' hours ago' + END + + ', oldest query finished ' + + CASE + WHEN g.OldestSec IS NULL THEN 'unknown' + WHEN g.OldestSec < 60 THEN CAST(g.OldestSec AS VARCHAR(10)) + ' seconds ago' + WHEN g.OldestSec < 3600 THEN CAST(g.OldestSec / 60 AS VARCHAR(10)) + ' minutes ago' + WHEN g.OldestSec < 86400 THEN CAST(g.OldestSec / 3600 AS VARCHAR(10)) + ' hours ' + + CAST((g.OldestSec % 3600) / 60 AS VARCHAR(10)) + ' minutes ago' + ELSE CAST(g.OldestSec / 86400 AS VARCHAR(10)) + ' days ' + + CAST((g.OldestSec % 86400) / 3600 AS VARCHAR(10)) + ' hours ago' + END + FROM ( + SELECT TOP (5) + ConnectionGroup = HostName, + ConnectionCount = COUNT(*), + MostRecentSec = DATEDIFF(SECOND, MAX(LastFinish), GETDATE()), + OldestSec = DATEDIFF(SECOND, MIN(LastFinish), GETDATE()) + FROM @ConnSessions + GROUP BY HostName + ORDER BY COUNT(*) DESC, HostName + ) g + ORDER BY g.ConnectionCount DESC, g.ConnectionGroup + FOR XML PATH(''), TYPE + ).value('text()[1]', 'nvarchar(max)'), 1, LEN(@LineFeed), N''); + + SELECT @TopLogins = STUFF(( + SELECT @LineFeed + + g.ConnectionGroup + ' - ' + CAST(g.ConnectionCount AS VARCHAR(20)) + ' connections' + + ', most recent query finished ' + + CASE + WHEN g.MostRecentSec IS NULL THEN 'unknown' + WHEN g.MostRecentSec < 60 THEN CAST(g.MostRecentSec AS VARCHAR(10)) + ' seconds ago' + WHEN g.MostRecentSec < 3600 THEN CAST(g.MostRecentSec / 60 AS VARCHAR(10)) + ' minutes ago' + WHEN g.MostRecentSec < 86400 THEN CAST(g.MostRecentSec / 3600 AS VARCHAR(10)) + ' hours ' + + CAST((g.MostRecentSec % 3600) / 60 AS VARCHAR(10)) + ' minutes ago' + ELSE CAST(g.MostRecentSec / 86400 AS VARCHAR(10)) + ' days ' + + CAST((g.MostRecentSec % 86400) / 3600 AS VARCHAR(10)) + ' hours ago' + END + + ', oldest query finished ' + + CASE + WHEN g.OldestSec IS NULL THEN 'unknown' + WHEN g.OldestSec < 60 THEN CAST(g.OldestSec AS VARCHAR(10)) + ' seconds ago' + WHEN g.OldestSec < 3600 THEN CAST(g.OldestSec / 60 AS VARCHAR(10)) + ' minutes ago' + WHEN g.OldestSec < 86400 THEN CAST(g.OldestSec / 3600 AS VARCHAR(10)) + ' hours ' + + CAST((g.OldestSec % 3600) / 60 AS VARCHAR(10)) + ' minutes ago' + ELSE CAST(g.OldestSec / 86400 AS VARCHAR(10)) + ' days ' + + CAST((g.OldestSec % 86400) / 3600 AS VARCHAR(10)) + ' hours ago' + END + FROM ( + SELECT TOP (5) + ConnectionGroup = LoginName, + ConnectionCount = COUNT(*), + MostRecentSec = DATEDIFF(SECOND, MAX(LastFinish), GETDATE()), + OldestSec = DATEDIFF(SECOND, MIN(LastFinish), GETDATE()) + FROM @ConnSessions + GROUP BY LoginName + ORDER BY COUNT(*) DESC, LoginName + ) g + ORDER BY g.ConnectionCount DESC, g.ConnectionGroup + FOR XML PATH(''), TYPE + ).value('text()[1]', 'nvarchar(max)'), 1, LEN(@LineFeed), N''); + + SELECT @TopApps = STUFF(( + SELECT @LineFeed + + g.ConnectionGroup + ' - ' + CAST(g.ConnectionCount AS VARCHAR(20)) + ' connections' + + ', most recent query finished ' + + CASE + WHEN g.MostRecentSec IS NULL THEN 'unknown' + WHEN g.MostRecentSec < 60 THEN CAST(g.MostRecentSec AS VARCHAR(10)) + ' seconds ago' + WHEN g.MostRecentSec < 3600 THEN CAST(g.MostRecentSec / 60 AS VARCHAR(10)) + ' minutes ago' + WHEN g.MostRecentSec < 86400 THEN CAST(g.MostRecentSec / 3600 AS VARCHAR(10)) + ' hours ' + + CAST((g.MostRecentSec % 3600) / 60 AS VARCHAR(10)) + ' minutes ago' + ELSE CAST(g.MostRecentSec / 86400 AS VARCHAR(10)) + ' days ' + + CAST((g.MostRecentSec % 86400) / 3600 AS VARCHAR(10)) + ' hours ago' + END + + ', oldest query finished ' + + CASE + WHEN g.OldestSec IS NULL THEN 'unknown' + WHEN g.OldestSec < 60 THEN CAST(g.OldestSec AS VARCHAR(10)) + ' seconds ago' + WHEN g.OldestSec < 3600 THEN CAST(g.OldestSec / 60 AS VARCHAR(10)) + ' minutes ago' + WHEN g.OldestSec < 86400 THEN CAST(g.OldestSec / 3600 AS VARCHAR(10)) + ' hours ' + + CAST((g.OldestSec % 3600) / 60 AS VARCHAR(10)) + ' minutes ago' + ELSE CAST(g.OldestSec / 86400 AS VARCHAR(10)) + ' days ' + + CAST((g.OldestSec % 86400) / 3600 AS VARCHAR(10)) + ' hours ago' + END + FROM ( + SELECT TOP (5) + ConnectionGroup = AppName, + ConnectionCount = COUNT(*), + MostRecentSec = DATEDIFF(SECOND, MAX(LastFinish), GETDATE()), + OldestSec = DATEDIFF(SECOND, MIN(LastFinish), GETDATE()) + FROM @ConnSessions + GROUP BY AppName + ORDER BY COUNT(*) DESC, AppName + ) g + ORDER BY g.ConnectionCount DESC, g.ConnectionGroup + FOR XML PATH(''), TYPE + ).value('text()[1]', 'nvarchar(max)'), 1, LEN(@LineFeed), N''); + + INSERT INTO #BlitzFirstResults (CheckID, Priority, FindingsGroup, Finding, URL, Details) + VALUES ( + 49, + 210, + 'Potential Upcoming Problems', + 'High Number of Connections', + 'https://www.brentozar.com/archive/2014/05/connections-slow-sql-server-threadpool/', + 'There are ' + CAST(@TotalConnections AS VARCHAR(20)) + ' open connections, which would lead to ' + @LineFeed + + 'worker thread exhaustion and THREADPOOL waits' + @LineFeed + + 'if they all ran queries at the same time.' + + @LineFeed + @LineFeed + 'Top 5 Servers:' + ISNULL(@TopServers, @LineFeed + '(none)') + + @LineFeed + @LineFeed + 'Top 5 Logins:' + ISNULL(@TopLogins, @LineFeed + '(none)') + + @LineFeed + @LineFeed + 'Top 5 Apps:' + ISNULL(@TopApps, @LineFeed + '(none)') + ); + END END END From cd914b713807f125dfb06cc20565bec9488429dc Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Wed, 15 Apr 2026 12:48:57 -0400 Subject: [PATCH 2/3] Revert version/date bump on line 50 Version and VersionDate on line 50 are maintained by the build process; hand-edits there get overwritten. Co-Authored-By: Claude Opus 4.6 (1M context) --- sp_BlitzFirst.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sp_BlitzFirst.sql b/sp_BlitzFirst.sql index da863acd..d60b4b5d 100644 --- a/sp_BlitzFirst.sql +++ b/sp_BlitzFirst.sql @@ -47,7 +47,7 @@ SET NOCOUNT ON; SET STATISTICS XML OFF; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -SELECT @Version = '8.33', @VersionDate = '20260415'; +SELECT @Version = '8.32', @VersionDate = '20260407'; IF(@VersionCheckMode = 1) BEGIN From dc10aef37eb3fdc73c8802948f6e55a510de12c2 Mon Sep 17 00:00:00 2001 From: Brent Ozar Date: Wed, 15 Apr 2026 12:58:11 -0400 Subject: [PATCH 3/3] sp_BlitzFirst CheckID 49: fix XPath and missing line break in Details output Two fixes based on Copilot review feedback on PR #3921: * Switch .value('text()[1]', 'nvarchar(max)') to .value(N'.[1]', N'NVARCHAR(MAX)') on all three Top-5 STUFF/FOR XML blocks. text()[1] returns only the first text node; when a program_name contains <, >, or & it gets entity-escaped and breaks the concatenation into multiple text nodes, so only the first row would appear. The new form matches the existing pattern used elsewhere in sp_BlitzFirst (see line 2487). * Insert @LineFeed between each "Top 5 X:" header and the first row. The Top-5 strings are built with STUFF(..., 1, LEN(@LineFeed), N'') so they don't start with a line break, which was running the first entry onto the header line. Co-Authored-By: Claude Opus 4.6 (1M context) --- sp_BlitzFirst.sql | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/sp_BlitzFirst.sql b/sp_BlitzFirst.sql index d60b4b5d..d7b6c553 100644 --- a/sp_BlitzFirst.sql +++ b/sp_BlitzFirst.sql @@ -2732,7 +2732,7 @@ If one of them is a lead blocker, consider killing that query.'' AS HowToStopit, ) g ORDER BY g.ConnectionCount DESC, g.ConnectionGroup FOR XML PATH(''), TYPE - ).value('text()[1]', 'nvarchar(max)'), 1, LEN(@LineFeed), N''); + ).value(N'.[1]', N'NVARCHAR(MAX)'), 1, LEN(@LineFeed), N''); SELECT @TopLogins = STUFF(( SELECT @LineFeed @@ -2769,7 +2769,7 @@ If one of them is a lead blocker, consider killing that query.'' AS HowToStopit, ) g ORDER BY g.ConnectionCount DESC, g.ConnectionGroup FOR XML PATH(''), TYPE - ).value('text()[1]', 'nvarchar(max)'), 1, LEN(@LineFeed), N''); + ).value(N'.[1]', N'NVARCHAR(MAX)'), 1, LEN(@LineFeed), N''); SELECT @TopApps = STUFF(( SELECT @LineFeed @@ -2806,7 +2806,7 @@ If one of them is a lead blocker, consider killing that query.'' AS HowToStopit, ) g ORDER BY g.ConnectionCount DESC, g.ConnectionGroup FOR XML PATH(''), TYPE - ).value('text()[1]', 'nvarchar(max)'), 1, LEN(@LineFeed), N''); + ).value(N'.[1]', N'NVARCHAR(MAX)'), 1, LEN(@LineFeed), N''); INSERT INTO #BlitzFirstResults (CheckID, Priority, FindingsGroup, Finding, URL, Details) VALUES ( @@ -2818,9 +2818,9 @@ If one of them is a lead blocker, consider killing that query.'' AS HowToStopit, 'There are ' + CAST(@TotalConnections AS VARCHAR(20)) + ' open connections, which would lead to ' + @LineFeed + 'worker thread exhaustion and THREADPOOL waits' + @LineFeed + 'if they all ran queries at the same time.' - + @LineFeed + @LineFeed + 'Top 5 Servers:' + ISNULL(@TopServers, @LineFeed + '(none)') - + @LineFeed + @LineFeed + 'Top 5 Logins:' + ISNULL(@TopLogins, @LineFeed + '(none)') - + @LineFeed + @LineFeed + 'Top 5 Apps:' + ISNULL(@TopApps, @LineFeed + '(none)') + + @LineFeed + @LineFeed + 'Top 5 Servers:' + @LineFeed + ISNULL(@TopServers, '(none)') + + @LineFeed + @LineFeed + 'Top 5 Logins:' + @LineFeed + ISNULL(@TopLogins, '(none)') + + @LineFeed + @LineFeed + 'Top 5 Apps:' + @LineFeed + ISNULL(@TopApps, '(none)') ); END END