Ads

27 July 2025

SQL Server Daily Health Check - Script


/* =======================================================================

   SQL Server Health Check – HTML Email (Final)

   - DBA's Heart Beat

   ======================================================================= */

SET NOCOUNT ON;


DECLARE @html    NVARCHAR(MAX) = N'';

DECLARE @newline NVARCHAR(10)  = CHAR(13) + CHAR(10);


-- =========================

-- HTML Header + Styles

-- =========================

SET @html = @html + N'<html><head><style>

body { font-family: Segoe UI, Arial, sans-serif; font-size: 13px; color: #222; }

h3 { margin: 18px 0 8px; }

table { border-collapse: collapse; width: auto; max-width: 100%; }

th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }

th { background-color: #f2f2f2; }

tr.alert { color: #b00020; font-weight: 600; }

.small { color:#666; font-size:12px; }

</style></head><body>' + @newline;


-- =========================

-- SQL Server Version & EOL

-- =========================

DECLARE @Servername NVARCHAR(128) = CAST(SERVERPROPERTY('ServerName') AS NVARCHAR(128));

DECLARE @version    NVARCHAR(128) = CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(128));

DECLARE @edition    NVARCHAR(128) = CAST(SERVERPROPERTY('Edition')        AS NVARCHAR(128));

DECLARE @eol        NVARCHAR(256);

DECLARE @MajorVersion NVARCHAR(256);


SET @MajorVersion = CASE

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '8%'    THEN 'SQL2000'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '9%'    THEN 'SQL2005'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '10.0%' THEN 'SQL2008'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '10.5%' THEN 'SQL2008 R2'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '11%'   THEN 'SQL2012'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '12%'   THEN 'SQL2014'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '13%'   THEN 'SQL2016'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '14%'   THEN 'SQL2017'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '15%'   THEN 'SQL2019'

     WHEN CONVERT(VARCHAR(128), SERVERPROPERTY('productversion')) LIKE '16%'   THEN 'SQL2022'

     ELSE 'Unknown/New Version'

END;


SET @eol = CASE

    WHEN @@VERSION LIKE '%2005%'    THEN 'Mainstream: 2011-04-12, Extended: 2016-04-12'

    WHEN @@VERSION LIKE '%2008 R2%' THEN 'Mainstream: 2014-07-08, Extended: 2019-07-09'

    WHEN @@VERSION LIKE '%2008%'    THEN 'Mainstream: 2014-07-08, Extended: 2019-07-09'

    WHEN @@VERSION LIKE '%2012%'    THEN 'Mainstream: 2017-07-11, Extended: 2022-07-12'

    WHEN @@VERSION LIKE '%2014%'    THEN 'Mainstream: 2019-07-09, Extended: 2024-07-09'

    WHEN @@VERSION LIKE '%2016%'    THEN 'Mainstream: 2021-07-13, Extended: 2026-07-14'

    WHEN @@VERSION LIKE '%2017%'    THEN 'Mainstream: 2022-10-11, Extended: 2027-10-12'

    WHEN @@VERSION LIKE '%2019%'    THEN 'Mainstream: 2025-02-28, Extended: 2030-01-08'

    WHEN @@VERSION LIKE '%2022%'    THEN 'Mainstream: 2028-01-11, Extended: 2033-01-11'

    ELSE 'Unknown/New Version'

END;


SET @html = @html + N'<h3>SQL Server Version and EOL</h3><table><tr>

<th>Instance Name</th><th>Version</th><th>PatchLevel</th><th>Edition</th><th>EOL Info</th></tr>';


SET @html = @html + N'<tr><td>' + @Servername + N'</td><td>' + @MajorVersion + N'</td><td>' +

             @version + N'</td><td>' + @edition + N'</td><td>' + @eol + N'</td></tr></table>' + @newline;


-- =========================

-- Uptime (Start + Duration)

-- =========================

SET @html = @html + N'<h3>SQL Server Uptime</h3><table><tr><th>Start Time</th><th>Up Since</th></tr>';


DECLARE @start DATETIME = (SELECT sqlserver_start_time FROM sys.dm_os_sys_info);

DECLARE @now   DATETIME = GETDATE();


DECLARE @diffSeconds BIGINT = DATEDIFF(SECOND, @start, @now);

DECLARE @days   INT    = @diffSeconds / 86400;

DECLARE @hours  INT    = (@diffSeconds % 86400) / 3600;

DECLARE @mins   INT    = (@diffSeconds % 3600) / 60;

DECLARE @secs   INT    = @diffSeconds % 60;


SELECT @html = @html +

    N'<tr><td>' + CONVERT(NVARCHAR(19), @start, 120) + N'</td><td>' +

    CAST(@days AS NVARCHAR(10)) + N' days ' +

    CAST(@hours AS NVARCHAR(10)) + N' hrs ' +

    CAST(@mins AS NVARCHAR(10)) + N' mins ' +

    CAST(@secs AS NVARCHAR(10)) + N' secs</td></tr>';


SET @html = @html + N'</table>' + @newline;


-- =========================

-- Offline Databases

-- =========================

SET @html = @html + N'<h3>Offline Databases</h3>';

IF EXISTS (SELECT 1 FROM sys.databases WHERE state_desc = 'OFFLINE')

BEGIN

    SET @html = @html + N'<table><tr><th>Database</th><th>Status</th></tr>';

    SELECT @html = @html + N'<tr><td>' + name + N'</td><td>' + state_desc + N'</td></tr>'

    FROM sys.databases WHERE state_desc = 'OFFLINE';

    SET @html = @html + N'</table>' + @newline;

END

ELSE

BEGIN

    SET @html = @html + N'<p>All databases are online.</p>' + @newline;

END


/* =======================================================================

   Database Default Locations + All Disks (Fixed Drive Letters only)

   WMI via OLE Automation – decimal math for Free%

   ======================================================================= */


-----------------------------

-- Database Default Locations

-----------------------------

DECLARE @DefaultData   NVARCHAR(4000) = CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS NVARCHAR(4000));

DECLARE @DefaultLog    NVARCHAR(4000) = CAST(SERVERPROPERTY('InstanceDefaultLogPath')  AS NVARCHAR(4000));

DECLARE @DefaultBackup NVARCHAR(4000);


BEGIN TRY

    SET @DefaultBackup = CAST(SERVERPROPERTY('InstanceDefaultBackupPath') AS NVARCHAR(4000));

END TRY

BEGIN CATCH

    SET @DefaultBackup = NULL;

END CATCH;


IF @DefaultBackup IS NULL OR LTRIM(RTRIM(@DefaultBackup)) = ''

BEGIN

    DECLARE @bk NVARCHAR(4000);

    EXEC master.dbo.xp_instance_regread

        N'HKEY_LOCAL_MACHINE',

        N'Software\Microsoft\MSSQLServer\MSSQLServer',

        N'BackupDirectory',

        @bk OUTPUT,

        'no_output';

    SET @DefaultBackup = @bk;

END


SET @html = @html + N'<h3>Database Default Locations</h3>'

           + N'<table><tr><th>Setting</th><th>Path</th></tr>'

           + N'<tr><td>Default Data</td><td>' + ISNULL(@DefaultData,'') + N'</td></tr>'

           + N'<tr><td>Default Log</td><td>'  + ISNULL(@DefaultLog,'')  + N'</td></tr>'

           + N'<tr><td>Default Backup</td><td>' + ISNULL(@DefaultBackup,'') + N'</td></tr>'

           + N'</table>' + @newline;


---------------------------------------------

-- All Disks: Fixed Drive Letters only (no \\?\Volume)

---------------------------------------------

IF OBJECT_ID('tempdb..#AllDisks') IS NOT NULL DROP TABLE #AllDisks;

CREATE TABLE #AllDisks

(

    DriveLetter NVARCHAR(10) NOT NULL,  -- 'C:'

    TotalBytes  BIGINT       NOT NULL,

    FreeBytes   BIGINT       NOT NULL

);


DECLARE @hr INT = 0, @locator INT = NULL, @svc INT = NULL, @set INT = NULL, @item INT = NULL, @count INT = 0, @i INT = 0;


BEGIN TRY

    -- Connect to WMI

    EXEC @hr = sp_OACreate 'WbemScripting.SWbemLocator', @locator OUT;

    IF @hr <> 0 BEGIN RAISERROR('Failed to create WMI locator (sp_OACreate).', 16, 1); RETURN; END;


    EXEC @hr = sp_OAMethod @locator, 'ConnectServer', @svc OUT, '.', 'root\cimv2';

    IF @hr <> 0 BEGIN RAISERROR('Failed to connect to WMI root\cimv2.', 16, 1); RETURN; END;


    -- Query: Fixed disks with letters (DriveType=3)

    EXEC @hr = sp_OAMethod @svc, 'ExecQuery', @set OUT,

        'SELECT DeviceID, Size, FreeSpace FROM Win32_LogicalDisk WHERE DriveType=3';

    IF @hr <> 0 BEGIN RAISERROR('WMI ExecQuery failed for Win32_LogicalDisk.', 16, 1); RETURN; END;


    EXEC @hr = sp_OAGetProperty @set, 'Count', @count OUT;

    SET @i = 0;

    WHILE @i < ISNULL(@count, 0)

    BEGIN

        EXEC @hr = sp_OAMethod @set, 'ItemIndex', @item OUT, @i;

        IF @hr <> 0 BREAK;


        DECLARE @DeviceID NVARCHAR(50), @Size NUMERIC(38,0), @Free NUMERIC(38,0);

        EXEC @hr = sp_OAGetProperty @item, 'DeviceID',  @DeviceID OUT;

        EXEC @hr = sp_OAGetProperty @item, 'Size',      @Size     OUT;

        EXEC @hr = sp_OAGetProperty @item, 'FreeSpace', @Free     OUT;


        -- Insert only if Size is present (>0)

        IF @Size IS NOT NULL AND @Size > 0

        BEGIN

            INSERT INTO #AllDisks(DriveLetter, TotalBytes, FreeBytes)

            VALUES (UPPER(@DeviceID), CONVERT(BIGINT, @Size), CONVERT(BIGINT, @Free));

        END


        SET @i = @i + 1;

    END

END TRY

BEGIN CATCH

    DECLARE @err NVARCHAR(4000) = ERROR_MESSAGE();

    RAISERROR('WMI/OLE disk collection failed: %s', 16, 1, @err);

END CATCH


-- Cleanup WMI objects

IF @set     IS NOT NULL BEGIN EXEC sp_OADestroy @set;     END;

IF @svc     IS NOT NULL BEGIN EXEC sp_OADestroy @svc;     END;

IF @locator IS NOT NULL BEGIN EXEC sp_OADestroy @locator; END;


-- Render All Disks (decimal math for Free%)

SET @html = @html + N'<h3>All Disks</h3>'

           + N'<table><tr>'

           + N'<th>Drive</th><th>Total (GB)</th><th>Free (GB)</th>'

           + N'<th>Used (GB)</th><th>Free (%)</th></tr>';


;WITH calc AS

(

    SELECT

        Drive      = DriveLetter,

        totalGB    = CONVERT(DECIMAL(18,2), TotalBytes / 1024.0 / 1024 / 1024),

        freeGB     = CONVERT(DECIMAL(18,2), FreeBytes  / 1024.0 / 1024 / 1024),

        usedGB     = CONVERT(DECIMAL(18,2), (TotalBytes - FreeBytes) / 1024.0 / 1024 / 1024)

    FROM #AllDisks

),

calc2 AS

(

    SELECT

        Drive, totalGB, freeGB, usedGB,

        freePct = CONVERT(DECIMAL(5,2), CASE WHEN totalGB > 0 THEN (freeGB * 100.00) / totalGB ELSE 0 END)

    FROM calc

)

SELECT @html = @html +

       CASE WHEN freePct < 10 THEN N'<tr class="alert">' ELSE N'<tr>' END +

       N'<td>' + Drive + N'</td><td>' +

       CONVERT(NVARCHAR(50), totalGB) + N'</td><td>' +

       CONVERT(NVARCHAR(50), freeGB)  + N'</td><td>' +

       CONVERT(NVARCHAR(50), usedGB)  + N'</td><td>' +

       CONVERT(NVARCHAR(50), freePct) + N'</td></tr>'

FROM calc2

ORDER BY Drive;


SET @html = @html + N'</table>' + @newline;


DROP TABLE #AllDisks;


-- =========================

-- Backup Status (exclude tempdb & mirrored DBs)

-- =========================

SET @html = @html + N'<h3>Backup Status (Failed or No Backup in Past 2 Days)</h3>';


IF EXISTS (

    SELECT 1

    FROM sys.databases d

    LEFT JOIN msdb.dbo.backupset bs ON bs.database_name = d.name

    LEFT JOIN sys.database_mirroring dm ON d.database_id = dm.database_id

    WHERE d.name <> 'tempdb'

      AND d.is_read_only = 0

      AND dm.mirroring_guid IS NULL

    GROUP BY d.name

    HAVING MAX(bs.backup_finish_date) IS NULL

        OR MAX(bs.backup_finish_date) < DATEADD(DAY, -2, GETDATE())

)

BEGIN

    SET @html = @html + N'<table><tr><th>Database</th><th>Last Backup</th></tr>';

    SELECT @html = @html + N'<tr><td>' + d.name + N'</td><td>' +

           COALESCE(CONVERT(NVARCHAR(19), MAX(bs.backup_finish_date), 120), N'No Backup') + N'</td></tr>'

    FROM sys.databases d

    LEFT JOIN msdb.dbo.backupset bs ON bs.database_name = d.name

    LEFT JOIN sys.database_mirroring dm ON d.database_id = dm.database_id

    WHERE d.name <> 'tempdb'

      AND d.is_read_only = 0

      AND dm.mirroring_guid IS NULL

    GROUP BY d.name

    HAVING MAX(bs.backup_finish_date) IS NULL

        OR MAX(bs.backup_finish_date) < DATEADD(DAY, -2, GETDATE());

    SET @html = @html + N'</table>' + @newline;

END

ELSE

BEGIN

    SET @html = @html + N'<p>All backups successful (within last 2 days).</p>' + @newline;

END


-- =========================

-- Mirroring Status

-- =========================

SET @html = @html + N'<h3>Mirroring Status</h3>';

IF EXISTS (SELECT 1 FROM sys.database_mirroring WHERE mirroring_guid IS NOT NULL)

BEGIN

    SET @html = @html + N'<table><tr><th>Database</th><th>Mirroring State</th><th>Safety Level</th></tr>';

    SELECT @html = @html + N'<tr><td>' + d.name + N'</td><td>' +

           ISNULL(dm.mirroring_state_desc, N'Unknown') + N'</td><td>' +

           ISNULL(dm.mirroring_safety_level_desc, N'Unknown') + N'</td></tr>'

    FROM sys.databases d

    JOIN sys.database_mirroring dm ON d.database_id = dm.database_id

    WHERE dm.mirroring_guid IS NOT NULL;

    SET @html = @html + N'</table>' + @newline;

END

ELSE

BEGIN

    SET @html = @html + N'<p>No mirroring configured.</p>' + @newline;

END




-- =========================

-- Replication Status (GUID-safe + resilient + idempotent)

-- =========================

SET @html = @html + N'<h3>Replication Status</h3>';


IF DB_ID('distribution') IS NOT NULL

BEGIN

    IF OBJECT_ID('distribution.dbo.MSreplication_monitordata') IS NOT NULL

    BEGIN

        -- Clean up any leftovers from previous runs in this session

        IF OBJECT_ID('tempdb..#DA')           IS NOT NULL DROP TABLE #DA;

        IF OBJECT_ID('tempdb..#MM')           IS NOT NULL DROP TABLE #MM;

        IF OBJECT_ID('tempdb..#JobLast')      IS NOT NULL DROP TABLE #JobLast;

        IF OBJECT_ID('tempdb..#MonitorRepl')  IS NOT NULL DROP TABLE #MonitorRepl;


        /* 1) Distribution Agents with publication + subscriber */

        SELECT

            p.publisher_db,

            a.publication,

            a.subscriber_db,

            a.job_id,

            job_id_str = UPPER(CONVERT(NVARCHAR(36), a.job_id))

        INTO #DA

        FROM distribution.dbo.MSdistribution_agents AS a

        INNER JOIN distribution.dbo.MSpublications   AS p

                ON p.publisher_id = a.publisher_id

               AND p.publication  = a.publication;


        /* 2) Monitor data: normalize job_id string (trim, uppercase, remove braces) */

        SELECT

            publisher_db       = m.publisher_db,

            publication        = m.publication,

            job_id_str         = NULLIF(UPPER(REPLACE(REPLACE(LTRIM(RTRIM(m.job_id)), '{', ''), '}', '')), ''),

            [Status]           = m.[Status],

            [Warning]          = m.[Warning],

            cur_latency        = m.cur_latency

        INTO #MM

        FROM distribution.dbo.MSreplication_monitordata AS m

        WHERE m.agent_type = 3  -- distribution agents

          AND NULLIF(LTRIM(RTRIM(m.job_id)), '') IS NOT NULL;


        /* 3) Last job outcome for each agent (fallback when MM has no rows) */

        SELECT

            j.job_id,

            job_id_str = UPPER(CONVERT(NVARCHAR(36), j.job_id)),

            last_status = ISNULL(h_last.run_status, -1)  -- -1 = unknown

        INTO #JobLast

        FROM msdb.dbo.sysjobs AS j

        INNER JOIN #DA        AS da ON da.job_id = j.job_id

        OUTER APPLY (

            SELECT TOP (1) run_status

            FROM msdb.dbo.sysjobhistory

            WHERE job_id = j.job_id AND step_id = 0

            ORDER BY run_date DESC, run_time DESC

        ) AS h_last;


        /* 4) Final projection: prefer MM status; else fallback to job history; else Unknown */

        SELECT DISTINCT

            da.publisher_db,

            da.publication,

            da.subscriber_db,

            Status =

                COALESCE(

                    /* MM status mapping */

                    CASE mm.[Status]

                        WHEN 1 THEN 'Started'

                        WHEN 2 THEN 'Succeeded'

                        WHEN 3 THEN 'In Progress'

                        WHEN 4 THEN 'Idle'

                        WHEN 5 THEN 'Retrying'

                        WHEN 6 THEN 'Failed'

                        ELSE NULL

                    END,

                    /* Job history mapping (fallback) */

                    CASE jl.last_status

                        WHEN 0 THEN 'Failed'

                        WHEN 1 THEN 'Succeeded'

                        WHEN 2 THEN 'Retrying'

                        WHEN 3 THEN 'Canceled'

                        WHEN 4 THEN 'In Progress'

                        ELSE 'Unknown'

                    END,

                    'Unknown'

                )

        INTO #MonitorRepl

        FROM #DA AS da

        LEFT JOIN #MM AS mm

               ON (mm.job_id_str   = da.job_id_str)

              OR (mm.publisher_db  = da.publisher_db AND mm.publication = da.publication)

        LEFT JOIN #JobLast AS jl

               ON jl.job_id        = da.job_id;


        /* 5) Render HTML */

        SET @html = @html + N'<table><tr>'

            + N'<th>Publisher DB</th><th>Publication</th><th>Subscriber DB</th><th>Status</th>'

            + N'</tr>';


        IF EXISTS (SELECT 1 FROM #MonitorRepl)

        BEGIN

            SELECT @html = @html + N'<tr><td>' + publisher_db + N'</td><td>' + publication

                             + N'</td><td>' + subscriber_db + N'</td><td>' + Status + N'</td></tr>'

            FROM #MonitorRepl

            ORDER BY publisher_db, publication, subscriber_db;

        END

        ELSE

        BEGIN

            SET @html = @html + N'<tr><td colspan="4">No replication agent rows found.</td></tr>';

        END


        SET @html = @html + N'</table>' + @newline;


        /* 6) Cleanup */

        IF OBJECT_ID('tempdb..#MonitorRepl') IS NOT NULL DROP TABLE #MonitorRepl;

        IF OBJECT_ID('tempdb..#JobLast')     IS NOT NULL DROP TABLE #JobLast;

        IF OBJECT_ID('tempdb..#MM')          IS NOT NULL DROP TABLE #MM;

        IF OBJECT_ID('tempdb..#DA')          IS NOT NULL DROP TABLE #DA;

    END

    ELSE

    BEGIN

        SET @html = @html + N'<p>No replication activity object (MSreplication_monitordata) found in distribution database.</p>' + @newline;

    END

END

ELSE

BEGIN

    SET @html = @html + N'<p>No replication configured.</p>' + @newline;

END


-- =========================

-- Always On Availability Groups

-- =========================

SET @html = @html + N'<h3>Always On Availability Groups</h3>';

IF EXISTS (SELECT 1 FROM sys.availability_groups)

BEGIN

    SET @html = @html + N'<table><tr>

        <th>Availability Group</th>

        <th>Replica Server</th>

        <th>Role</th>

        <th>Database</th>

        <th>Sync State</th>

        <th>Health</th>

    </tr>';


    ;WITH ags AS (

        SELECT DISTINCT

               ag.name                           AS AGName,

               ar.replica_server_name            AS ReplicaServer,

               ars.role_desc                     AS RoleDesc,

               adc.database_name                 AS DBName,

               dhs.synchronization_state_desc    AS SyncState,

               dhs.synchronization_health_desc   AS Health

        FROM sys.availability_groups ag

        JOIN sys.availability_replicas ar

             ON ag.group_id = ar.group_id

        JOIN sys.dm_hadr_availability_replica_states ars

             ON ar.replica_id = ars.replica_id

        JOIN sys.availability_databases_cluster adc

             ON ag.group_id = adc.group_id

        JOIN sys.dm_hadr_database_replica_states dhs

             ON ars.replica_id = dhs.replica_id

            AND dhs.group_database_id = adc.group_database_id

    )

    SELECT @html = @html + N'<tr><td>' + AGName + N'</td><td>' + ReplicaServer + N'</td><td>' +

           RoleDesc + N'</td><td>' + DBName + N'</td><td>' + SyncState + N'</td><td>' + Health + N'</td></tr>'

    FROM ags

    ORDER BY AGName, ReplicaServer, DBName;


    SET @html = @html + N'</table>' + @newline;

END

ELSE

BEGIN

    SET @html = @html + N'<p>No Always On Availability Groups configured.</p>' + @newline;

END


-- =========================

-- HTML Footer

-- =========================

SET @html = @html + N'</body></html>';


-- =========================

-- Send Email

-- =========================

DECLARE @sub VARCHAR(MAX);

SET @sub = 'Alert: SQL Server Health Check Report - ' + CAST(SERVERPROPERTY('MachineName') AS VARCHAR(128));


EXEC msdb.dbo.sp_send_dbmail

    @profile_name = 'Default',

    @recipients   = 'DBA@sqlserver.com',

    @subject      = @sub,

    @body         = @html,

    @body_format  = 'HTML';