While reading a blog post regarding using server DDL triggers to capture a “CREATE DATABASE” and fire an email to the DBA, I thought that coupling a trigger to sp_send_dbmail and an external executable wasn’t a great idea. I decided that there must be a better way to capture that important event. For me, this would really only occur in a development server where the developers often have enhanced rights to the dev servers. Production is pretty tight. However, my heart is always broken when a developer runs up and asks me to restore an accidentally dropped database on the Dev server, only for me to find out that the same developer created that database without telling me. My backup packages don’t dynamically grab all the online databases when it runs, so if they add a database and don’t tell me, it don’t get backed up.
My first thought was Extended Events. I don’t know much about them, I’ve heard the abstracts and read the rumors, but I haven’t had call to work with them. If I remember correctly, it seemed there was a way to capture the CREATE Database event, but then I was stuck with a data record of the event, and not a handy way to email it to me. Searching for Extended Events Send Email took me quickly to a Johnathan Kehayias awesome answer on the MSDN forums explaining that, no, there’s no real plumbing between Extended Events and the Service Broker. His second answer in the same discussion linked to his blog which is fountain of knowledge and an article explaining Event Notifications vs Extended Events. From there, I saw a link to another article in his blog. Sounded pretty darn close to what I wanted.
That lead to my current version of a generic Event Notification system which I am currently testing on a number of servers. It’s actually a combination of procedures from Jonathan’s articles and Sergey Maskalik’s article. Sergey’s error handling and timeout on the Waitfor along with cleanup of the Conversation Handles coupled with Jonathan’s shredding of the XML message body seems to be a work of art to me. I added in some of my own magic to ensure XACT_ABORT was on, some COALESCE’s to ensure a null value wasn’t concatenated over valid values, and setting this up in a “utility” database and setting “TRUSTWORTHY ON” to allow the execution of sp_send_dbmail in MSDB.
We’ll start with the guts needed to set up and wire up the Service Broker and Queues, Services, and Routes. It’s pretty boiler plate, with the added commands to turn on the Service Broker and set TRUSTWORTHY.
NOTE: Jonathan has visited and brought up a very valid and real security risk. In my approach, I take a utility database and set TRUSTWORTHY=ON to it. Please visit and read Raul Garcia’s article on the risks of the TRUSTWORTHY bit. At the time of my writing this, the database that I deploy this solution to is already restricted to the DBA team and anyone with Sysadmin privileges. In this case, all those principals already have enough access to do what this setting allows without any other work, so I feel the risk in my environment is low. For a more secure solution, I strongly recommend a careful review of your situation, and indeed using certificates to sign the procedures to allow cross-database execution.
USE [master];
go
-- SET EVERYTHING UP WITH THE SERVICE BROKER
-- We could also do this when creating the DatabaseBackup database
-- as part of the initial package run, or update.
-- Trustworthy allows a stored proc in the current database
-- execute SP_SEND_DBMAIL in msdb
ALTER DATABASE DatabaseBackup SET ENABLE_BROKER;
go
ALTER DATABASE DatabaseBackup SET TRUSTWORTHY ON;
go
USE DatabaseBackup
GO
-- Drop the notification if it exists
IF EXISTS ( SELECT *
FROM sys.server_event_notifications
WHERE name = N'CaptureDBAEvents' )
BEGIN
DROP EVENT NOTIFICATION [CaptureDBAEvents] ON SERVER;
END
-- Drop the route if it exists
IF EXISTS ( SELECT *
FROM sys.routes
WHERE name = N'DBAEventRoute' )
BEGIN
DROP ROUTE [DBAEventRoute];
END
-- Drop the service if it exists
IF EXISTS ( SELECT *
FROM sys.services
WHERE name = N'DBAEventService' )
BEGIN
DROP SERVICE [DBAEventService];
END
-- Drop the queue if it exists
IF EXISTS ( SELECT *
FROM sys.service_queues
WHERE name = N'DBAEventQueue' )
BEGIN
DROP QUEUE [DBAEventQueue];
END
IF EXISTS ( SELECT *
FROM MASTER.sys.event_notifications
WHERE name = N'CaptureDBAEvents' )
BEGIN
DROP EVENT NOTIFICATION [CaptureDBAEvents] ON SERVER
END
-- Create a service broker queue to hold the events
CREATE QUEUE [DBAEventQueue]
WITH STATUS=ON;
GO
-- Create a service broker service receive the events
CREATE SERVICE [DBAEventService]
ON QUEUE [DBAEventQueue] ([http://schemas.microsoft.com/SQL/Notifications/PostEventNotification]);
GO
-- Create a service broker route to the service
CREATE ROUTE [DBAEventRoute]
WITH SERVICE_NAME = 'DBAEventService',
ADDRESS = 'LOCAL';
GO
-- Create the event notification to capture the events
CREATE EVENT NOTIFICATION [CaptureDBAEvents]
ON SERVER
WITH FAN_IN
FOR CREATE_DATABASE, DROP_DATABASE, CREATE_LOGIN, DROP_LOGIN, CREATE_USER, DROP_USER, BLOCKED_PROCESS_REPORT, DEADLOCK_GRAPH, ADD_ROLE_MEMBER, ADD_SERVER_ROLE_MEMBER
TO SERVICE 'DBAEventService', 'current database';
GO
Right above, while creating the EVENT NOTIFICATION, you can see the event types I have. I decided that while it’s great to have AutoGrowth events sent, that in our current environment, this might be more noise than there is value for, so we have left that out for now. Sure there’s a lot more audit events that I could hit up too, but I felt that the ROLL MEMEBERSHIPS, USER and LOGIN work and the DATABASE create and drop were a great start. Also the DEADLOCK_GRAPH was just a nice freebie.
Next, the guts of this, an Stored Procedure that is generic enough to handle different Event Types and shred as much of the XML as possible into a friendly email message. Right, who doesn’t like a mailbox full of raw XML in the morning? 🙂 Note, the final ELSE in the shredding and Email Body building, so if we decided to add a Event Type, we will always just email off the XML until things are fixed.
USE DatabaseBackup
GO
-- Drop the procedure if it exists
IF EXISTS ( SELECT *
FROM sys.procedures
WHERE name = N'ProcessEvents' )
BEGIN
DROP PROCEDURE [ProcessEvents];
END
GO
CREATE PROCEDURE [dbo].[ProcessEvents]
WITH EXECUTE AS OWNER
AS
SET XACT_ABORT ON;
DECLARE @eventType VARCHAR(128);
DECLARE @messagetypename NVARCHAR(256);
DECLARE @ch UNIQUEIDENTIFIER;
DECLARE @serverName VARCHAR(128);
DECLARE @postTime VARCHAR(128);
DECLARE @databaseName VARCHAR(128);
DECLARE @duration VARCHAR(128);
DECLARE @growthPages INT;
DECLARE @userName VARCHAR(128);
DECLARE @loginInfo VARCHAR(256);
DECLARE @SID VARCHAR(128);
DECLARE @messageBody XML;
DECLARE @emailTo VARCHAR(50);
DECLARE @emailBody VARCHAR(MAX);
DECLARE @subject varchar(150);
SET @emailTo = '<DBA TEAM EMAIL HERE>@gmail.com;
WHILE (1=1)
BEGIN
BEGIN TRY
BEGIN TRANSACTION
WAITFOR (
RECEIVE TOP(1)
@ch = conversation_handle,
@messagetypename = message_type_name,
@messagebody = CAST(message_body AS XML)
FROM DBAEventQueue
), TIMEOUT 60000;
IF (@@ROWCOUNT = 0)
BEGIN
ROLLBACK TRANSACTION;
BREAK;
END
IF (@messagetypename = 'http://schemas.microsoft.com/SQL/Notifications/EventNotification')
BEGIN
-- Get the common information
SELECT @eventType = COALESCE(@messagebody.value('(/EVENT_INSTANCE/EventType)[1]','varchar(128)'),'UNKNOWN'),
@serverName = COALESCE(@messagebody.value('(/EVENT_INSTANCE/ServerName)[1]','varchar(128)'),'UNKNOWN'),
@postTime = COALESCE(CAST(@messagebody.value('(/EVENT_INSTANCE/PostTime)[1]','datetime') AS VARCHAR),'UNKNOWN');
SELECT @emailBody = 'The following event occurred:' + CHAR(10)
+ CAST('Event Type: ' AS CHAR(25)) + @EventType + CHAR(10)
+ CAST('ServerName: ' AS CHAR(25)) + @ServerName + CHAR(10)
+ CAST('PostTime: ' AS CHAR(25)) + @PostTime + CHAR(10);
-- Now the custom XML fields depending on the Event Type
IF (@EventType like '%_FILE_AUTO_GROW')
BEGIN
SELECT @duration = COALESCE(@messagebody.value('(/EVENT_INSTANCE/Duration)[1]','varchar(128)'),'UNKNOWN'),
@growthPages = COALESCE(@messagebody.value('(/EVENT_INSTANCE/IntegerData)[1]', 'int'),'UNKNOWN'),
@databaseName = COALESCE(@messagebody.value('(/EVENT_INSTANCE/DatabaseName)[1]','varchar(128)'),'UNKNOWN');
SELECT @emailBody = @emailBody
+ CAST('Duration: ' AS CHAR(25)) + @Duration + CHAR(10)
+ CAST('GrowthSize_KB: ' AS CHAR(25)) + CAST(( @GrowthPages * 8 ) AS VARCHAR(20)) + CHAR(10)
+ CAST('DatabaseName: ' AS CHAR(25)) + @DatabaseName + CHAR(10);
END
ELSE IF (@EventType like '%_DATABASE')
BEGIN
SELECT @userName = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/LoginName[1]', 'varchar(128)'),'UNKNOWN'),
@DatabaseName = COALESCE(@messagebody.value('(/EVENT_INSTANCE/DatabaseName)[1]','varchar(128)'),'UNKNOWN');
SELECT @emailBody = @emailBody
+ CAST('User: ' AS CHAR(25)) + @userName + CHAR(10)
+ CAST('DatabaseName: ' AS CHAR(25)) + @DatabaseName + CHAR(10);
END
ELSE IF (@EventType like '%_LOGIN')
BEGIN
SELECT @userName = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/LoginName[1]', 'varchar(128)'),'UNKNOWN'),
@loginInfo = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/ObjectName[1]', 'varchar(256)'),'UNKNOWN'),
@SID = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/SID[1]', 'varchar(128)'),'UNKNOWN');
SELECT @emailBody = @emailBody
+ CAST('User: ' AS CHAR(25)) + @userName + CHAR(10)
+ CAST('New User: ' AS CHAR(25)) + @loginInfo + CHAR(10)
+ CAST('New SID: ' AS CHAR(25)) + @SID + CHAR(10);
END
ELSE IF (@EventType like '%_ROLE_MEMBER')
BEGIN
DECLARE @roleName VARCHAR(128);
DECLARE @command VARCHAR(128);
SELECT @userName = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/LoginName[1]', 'varchar(128)'),'UNKNOWN'),
@loginInfo = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/ObjectName[1]', 'varchar(256)'),'UNKNOWN'),
@roleName = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/RoleName[1]', 'varchar(256)'),'UNKNOWN'),
@command = COALESCE(@messageBody.value('/EVENT_INSTANCE[1]/TSQLCommand[1]/CommandText[1]', 'varchar(256)'),'UNKNOWN');
SELECT @emailBody = @emailBody
+ CAST('User: ' AS CHAR(25)) + @userName + CHAR(10)
+ CAST('Affected User: ' AS CHAR(25)) + @loginInfo + CHAR(10)
+ CAST('New Role: ' AS CHAR(25)) + @roleName + CHAR(10)
+ CAST('Command issued: ' AS CHAR(25)) + @command + CHAR(10);
END
ELSE -- TRAP ALL OTHER EVENTS AND SPIT OUT JUST THE XML - We can pretty it up later :)
BEGIN
SELECT @emailBody = CAST(@messagebody AS VARCHAR(max));
END
-- Send email using Database Mail
SELECT @subject = @eventType + ' on ' + @serverName;
EXEC msdb.dbo.sp_send_dbmail
@profile_name = 'DBA Email', -- your defined email profile
@recipients = @emailTo, -- your email
@subject = @subject,
@body = @emailBody;
END
IF (@messagetypename = 'http://schemas.microsoft.com/SQL/ServiceBroker/Error')
BEGIN
DECLARE @errorcode INT;
DECLARE @errormessage NVARCHAR(3000) ;
-- Extract the error information from the sent message
SET @errorcode = (SELECT @messagebody.value(
N'declare namespace brokerns="http://schemas.microsoft.com/SQL/ServiceBroker/Error";
(/brokerns:Error/brokerns:Code)[1]', 'int'));
SET @errormessage = (SELECT @messagebody.value(
N'declare namespace brokerns="http://schemas.microsoft.com/SQL/ServiceBroker/Error";
(/brokerns:Error/brokerns:Description)[1]', 'nvarchar(3000)'));
-- Log the error
END CONVERSATION @ch WITH CLEANUP;
END
IF (@messagetypename = 'http://schemas.microsoft.com/SQL/ServiceBroker/EndDialog')
BEGIN
-- End the conversation
END CONVERSATION @ch WITH CLEANUP;
END
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
DECLARE @ErrorNum INT;
DECLARE @ErrorMsg NVARCHAR(3000);
SELECT @ErrorNum = ERROR_NUMBER(), @ErrorMsg = ERROR_MESSAGE();
-- log the error
BREAK;
END CATCH
END
GO
Finally, let’s activate the new Stored Procedure by altering the Queue. Again this is pretty boiler plate.
-- Activate the procedure with the Queue
ALTER QUEUE [DBAEventQueue]
WITH STATUS=ON,
ACTIVATION
(STATUS=ON,
PROCEDURE_NAME = [ProcessEvents],
MAX_QUEUE_READERS = 1,
EXECUTE AS OWNER);
GO
Thanks, I hope that helps anyone interested in Event Notifications.