SQL Server Alert for Long Running Queries: Full Setup

· 9 MIN

TL;DR: SQL Server has no built-in "query ran too long" alert, so you build one: a stored procedure reads sys.dm_exec_requests for user requests past a threshold, emails them with sp_send_dbmail, and logs what it sent so the same query never alerts twice. A SQL Server Agent job runs it every minute. The full script is below and takes about 15 minutes to set up.

To get a SQL Server alert for long running queries, schedule a check of the sys.dm_exec_requests DMV, whose total_elapsed_time column is the milliseconds since each request arrived, and email any user request over your threshold through Database Mail. SQL Server Agent's own alerts react to errors and performance counters, not to query duration, and the one counter that sounds right, Longest Transaction Running Time, only shows activity under read committed snapshot isolation. The forum answers that rank for this question each cover one piece (a job-history query, a mail snippet, an AWS Lambda build). This page puts the working pieces together: the DMV query, Database Mail, the Agent job, exclusions for backups and maintenance, and protection against repeat emails.

How the long running query alert works, from the Agent schedule to the email
FIG 01 — Each run either sends one email or exits quietly.

What you need

Which DMV shows currently running queries and their duration?

sys.dm_exec_requests shows every request executing right now, one row per request, with start_time, status (running, runnable, suspended, sleeping, background or rollback), command, wait_type, blocking_session_id and total_elapsed_time in milliseconds. Join it to sys.dm_exec_sessions for the login, host and application, and to sys.dm_exec_sql_text(sql_handle) for the text. Run this first to see what the alert will see:

-- user requests running longer than 5 minutes, longest first
SELECT
    r.session_id,
    r.total_elapsed_time / 1000 AS elapsed_seconds,
    r.status,
    r.command,
    r.wait_type,
    r.blocking_session_id,
    DB_NAME(r.database_id) AS database_name,
    s.login_name,
    s.host_name,
    s.program_name,
    SUBSTRING(t.text, (r.statement_start_offset / 2) + 1,
        ((CASE r.statement_end_offset
            WHEN -1 THEN DATALENGTH(t.text)
            ELSE r.statement_end_offset END
          - r.statement_start_offset) / 2) + 1) AS running_statement
FROM sys.dm_exec_requests AS r
JOIN sys.dm_exec_sessions AS s ON s.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE s.is_user_process = 1
  AND r.session_id <> @@SPID
  AND r.total_elapsed_time >= 300000
ORDER BY r.total_elapsed_time DESC;

The offsets are in bytes, and the text is Unicode, so dividing by 2 cuts out the exact statement that is running rather than the whole batch or procedure. is_user_process = 1 drops system tasks such as ghost cleanup. If you want to check a query plan once you've found the culprit, the same reading habits apply as in our guide to reading a query plan with EXPLAIN.

What the DMV check returns and which rows become an alert
FIG 02 — The backup is excluded and the 40-second update is under the threshold.

How to set up the alert, step by step

  1. Enable Database Mail. Database Mail XPs is off by default. Turn it on (the Database Mail Configuration Wizard in SSMS does the same thing):
   EXEC sp_configure 'show advanced options', 1;
   RECONFIGURE;
   EXEC sp_configure 'Database Mail XPs', 1;
   RECONFIGURE;
  1. Create a mail account and profile, then send a test. Use your own SMTP host and an app password, not a personal mailbox password:
   EXEC msdb.dbo.sysmail_add_account_sp
       @account_name    = N'DBA Alerts SMTP',
       @email_address   = N'sql-alerts@example.com',
       @display_name    = N'SQL Server Alerts',
       @mailserver_name = N'smtp.example.com',
       @port            = 587,
       @enable_ssl      = 1,
       @username        = N'sql-alerts@example.com',
       @password        = N'replace-with-app-password';

   EXEC msdb.dbo.sysmail_add_profile_sp
       @profile_name = N'DBA Alerts';

   EXEC msdb.dbo.sysmail_add_profileaccount_sp
       @profile_name    = N'DBA Alerts',
       @account_name    = N'DBA Alerts SMTP',
       @sequence_number = 1;

   EXEC msdb.dbo.sp_send_dbmail
       @profile_name = N'DBA Alerts',
       @recipients   = 'dba-team@example.com',
       @subject      = N'Database Mail test',
       @body         = N'If you can read this, Database Mail works.';

sp_send_dbmail only queues the message. Confirm it left the server with SELECT TOP (5) mailitem_id, sent_status, sent_date FROM msdb.dbo.sysmail_allitems ORDER BY mailitem_id DESC; and look for sent, not failed or retrying.

  1. Create the log table that stops repeat alerts. A request is uniquely identified by its session and start time, so that pair is the key:
   USE DBAdmin;
   GO
   CREATE TABLE dbo.long_query_alert_log (
       session_id    smallint     NOT NULL,
       request_start datetime     NOT NULL,
       alerted_at    datetime2(0) NOT NULL DEFAULT SYSDATETIME(),
       CONSTRAINT PK_long_query_alert_log PRIMARY KEY (session_id, request_start)
   );
   GO
  1. Create the alert procedure. It collects requests over the threshold, drops maintenance work and anything already alerted, emails an HTML table, then records what it sent:
   CREATE OR ALTER PROCEDURE dbo.usp_alert_long_running_queries
       @threshold_seconds int          = 300,
       @recipients        varchar(max) = 'dba-team@example.com',
       @profile_name      sysname      = N'DBA Alerts'
   AS
   BEGIN
       SET NOCOUNT ON;

       SELECT
           r.session_id,
           r.start_time,
           r.total_elapsed_time / 1000 AS elapsed_seconds,
           r.status,
           r.wait_type,
           r.blocking_session_id,
           DB_NAME(r.database_id) AS database_name,
           s.login_name,
           s.host_name,
           LEFT(SUBSTRING(t.text, (r.statement_start_offset / 2) + 1,
               ((CASE r.statement_end_offset
                   WHEN -1 THEN DATALENGTH(t.text)
                   ELSE r.statement_end_offset END
                 - r.statement_start_offset) / 2) + 1), 500) AS statement_text
       INTO #long_running
       FROM sys.dm_exec_requests AS r
       JOIN sys.dm_exec_sessions AS s ON s.session_id = r.session_id
       OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
       LEFT JOIN msdb.dbo.sysjobs AS j
           ON s.program_name LIKE 'SQLAgent - TSQL JobStep%'
          AND j.job_id = TRY_CONVERT(uniqueidentifier,
                TRY_CONVERT(binary(16), SUBSTRING(s.program_name, 30, 34), 1))
       WHERE s.is_user_process = 1
         AND r.session_id <> @@SPID
         AND r.total_elapsed_time >= @threshold_seconds * 1000
         -- maintenance that is allowed to run long
         AND r.command NOT LIKE 'BACKUP%'
         AND r.command NOT LIKE 'RESTORE%'
         AND r.command NOT LIKE 'DBCC%'
         -- Agent jobs that are allowed to run long (edit to your job names)
         AND ISNULL(j.name, N'') NOT LIKE N'IndexOptimize%'
         AND ISNULL(j.name, N'') NOT LIKE N'DatabaseIntegrityCheck%'
         -- one alert per request
         AND NOT EXISTS (
             SELECT 1 FROM dbo.long_query_alert_log AS l
             WHERE l.session_id = r.session_id
               AND l.request_start = r.start_time);

       IF NOT EXISTS (SELECT 1 FROM #long_running) RETURN;

       DECLARE @subject nvarchar(255) = N'[SQL alert] '
           + CAST((SELECT COUNT(*) FROM #long_running) AS nvarchar(10))
           + N' long running queries on ' + @@SERVERNAME;

       DECLARE @body nvarchar(max) =
           N'<p>Requests running longer than ' + CAST(@threshold_seconds AS nvarchar(10))
           + N' seconds on ' + @@SERVERNAME + N'</p>'
           + N'<table border="1" cellpadding="4"><tr><th>Session</th><th>Seconds</th>'
           + N'<th>Database</th><th>Login</th><th>Host</th><th>Status / wait</th>'
           + N'<th>Blocked by</th><th>Statement</th></tr>'
           + CAST((
               SELECT td = session_id, '',
                      td = elapsed_seconds, '',
                      td = ISNULL(database_name, ''), '',
                      td = login_name, '',
                      td = ISNULL(host_name, ''), '',
                      td = status + ISNULL(N' / ' + wait_type, N''), '',
                      td = blocking_session_id, '',
                      td = ISNULL(statement_text, '')
               FROM #long_running
               ORDER BY elapsed_seconds DESC
               FOR XML PATH('tr'), TYPE) AS nvarchar(max))
           + N'</table>';

       EXEC msdb.dbo.sp_send_dbmail
           @profile_name = @profile_name,
           @recipients   = @recipients,
           @subject      = @subject,
           @body         = @body,
           @body_format  = 'HTML';

       INSERT dbo.long_query_alert_log (session_id, request_start)
       SELECT DISTINCT session_id, start_time FROM #long_running;

       -- keep the log small
       DELETE dbo.long_query_alert_log
       WHERE alerted_at < DATEADD(DAY, -7, SYSDATETIME());
   END;
   GO

FOR XML PATH escapes < and > inside the statement text, so a query containing comparison operators can't break the email's HTML.

  1. Schedule it with a SQL Server Agent job that runs every minute. In sp_add_schedule, @freq_type = 4 means daily and @freq_subday_type = 4 means the interval is in minutes:
   USE msdb;
   GO
   EXEC dbo.sp_add_job
       @job_name    = N'DBA - Alert long running queries',
       @description = N'Emails when a user request runs past the threshold';

   EXEC dbo.sp_add_jobstep
       @job_name      = N'DBA - Alert long running queries',
       @step_name     = N'Check DMVs and send mail',
       @subsystem     = N'TSQL',
       @database_name = N'DBAdmin',
       @command       = N'EXEC dbo.usp_alert_long_running_queries @threshold_seconds = 300;';

   EXEC dbo.sp_add_schedule
       @schedule_name        = N'Every 1 minute',
       @freq_type            = 4,  -- daily
       @freq_interval        = 1,
       @freq_subday_type     = 4,  -- minutes
       @freq_subday_interval = 1;

   EXEC dbo.sp_attach_schedule
       @job_name      = N'DBA - Alert long running queries',
       @schedule_name = N'Every 1 minute';

   EXEC dbo.sp_add_jobserver
       @job_name = N'DBA - Alert long running queries';
   GO

A one-minute schedule means a query is reported between 0 and 60 seconds after it crosses the threshold. That is fine for a 5-minute threshold. For a tighter threshold, the schedule can go down to 10 seconds (@freq_subday_type = 2), but each run costs a DMV scan.

How to check that it works

Open a new query window and start a request that will cross a short test threshold:

-- session A: simulate a slow query
WAITFOR DELAY '00:02:00';

Then, in another window, run the procedure with a 60-second threshold once the delay has passed one minute:

EXEC DBAdmin.dbo.usp_alert_long_running_queries @threshold_seconds = 60;
SELECT * FROM DBAdmin.dbo.long_query_alert_log;

You should get one email listing session A with the WAITFOR statement and a row in the log table. Run the procedure again while session A is still waiting: no second email, because the session and start time are already logged. Finally, check the Agent job history (right-click the job, View History) to confirm the scheduled runs succeed.

How do you exclude backups and maintenance jobs from the alert?

Exclude them by what the DMV tells you about the request. Backups, restores and DBCC checks show up in the command column (BACKUP DATABASE, BACKUP LOG, RESTORE DATABASE and the DBCC family), so the NOT LIKE filters in the procedure catch them whoever starts them. Agent jobs are different: their sessions report program_name as SQLAgent - TSQL JobStep (Job 0x... : Step n), with the job id as hex starting at character 30. The procedure converts that back to a job_id, joins msdb.dbo.sysjobs and filters by job name. Replace the two example names with your own index maintenance, ETL or reporting jobs. To exclude an application (a nightly export tool, for example), add AND s.program_name NOT LIKE N'YourApp%'.

Keep the exclusion list short and explicit. Every pattern you add is a query that can hang without anyone hearing about it.

How do you avoid repeat alerts for the same query?

Log every alerted request by session_id plus start_time and skip anything already in the log. A session can run many requests over its life, but each new request gets a new start_time, so the next slow query on the same connection still alerts while the one you already know about stays quiet. The procedure also deletes log rows older than seven days.

The log table turns three checks of the same request into one email
FIG 03 — Session 57 started a second slow request at 10:01, so it alerts again.

If you'd rather be reminded about a query that keeps running, add a reminder rule instead of removing the log: delete the log row once alerted_at is older than, say, 30 minutes, and the next run will email again.

Common errors and fixes

Symptom or messageCauseFix
Msg 15281: SQL Server blocked access to procedure 'dbo.sp_send_dbmail' of component 'Database Mail XPs' because this component is turned off...Database Mail XPs is 0 (the default)Run step 1
Msg 14607: profile name is not validThe profile doesn't exist, or the caller can't use itCheck the name, make it public with sysmail_add_principalprofile_sp @principal_name = 'public', or add the user to DatabaseMailUserRole in msdb
Email never arrives, sent_status is failedSMTP host, port, TLS or credentials are wrongRead msdb.dbo.sysmail_event_log for the SMTP error, fix the account with sysmail_update_account_sp
The job succeeds but never alertsThe job owner lacks VIEW SERVER STATE (or VIEW SERVER PERFORMANCE STATE on 2022+), so it only sees its own sessionMake a sysadmin the job owner or grant the permission
Alerts for every backup at nightThe exclusion filters were removed or your backup tool runs as a different commandAdd its command or program_name to the filters

Alternatives to an Agent job

The Agent job is the right default on a single SQL Server instance because it needs no extra software. Other options fit other setups:

For other scheduled checks, the pattern is the same as scheduling a query in Postgres with pg_cron: a stored query, a schedule and somewhere for the result to go.

Getting the alert somewhere other than email

Database Mail only sends email, and SMTP setup is the step that breaks most often. If your team lives in Slack, or you want to watch business conditions as well as server health (a stuck order queue, a payment table that stopped growing), AI for Database connects to SQL Server read-only, runs a check you describe in plain English on a schedule, and sends it to Slack, email or a webhook when a threshold is crossed. Try AI for Database free at aifordatabase.com.

Frequently Asked Questions

Which DMV shows currently running queries and their duration?

sys.dm_exec_requests. Its total_elapsed_time column is the time in milliseconds since the request arrived, and start_time is when it began. Join sys.dm_exec_sessions for the login and host and sys.dm_exec_sql_text(sql_handle) for the query text.

How do you email an alert from SQL Server Agent?

Enable Database Mail XPs, create a Database Mail account and profile, then call msdb.dbo.sp_send_dbmail with @profile_name, @recipients, @subject and @body from a T-SQL job step. Agent's built-in operator notifications only fire on job outcomes and Agent alerts, not on query duration.

Can SQL Server Agent alert on long running queries without a script?

Not reliably. Agent alerts fire on error numbers, severities, performance counter thresholds and WMI events. The counter that looks right, SQLServer:Transactions Longest Transaction Running Time, only reports activity for databases using read committed snapshot isolation, so it misses most workloads.

How often should the alert job run?

Every minute suits thresholds of a few minutes or more. The schedule can go as low as 10 seconds, but frequent DMV scans and emails add load and noise. Pick the threshold from your workload: a reporting server might use 15 minutes, an OLTP database 60 seconds.

Does this work on Azure SQL Database?

No. Azure SQL Database has no SQL Server Agent and VIEW SERVER STATE can't be granted there, so sys.dm_exec_requests only shows your own connection. Use elastic jobs or Azure Monitor alerts instead. Azure SQL Managed Instance does support the Agent approach.

Will the alert job show up in its own results?

No. The procedure filters out @@SPID, the session running the check itself.

Ready to try AI for Database?

Query your database in plain English. No SQL required. Start free today.