SQL Server Audit Schema Changes – Part 2: DDL Changes Logged to a Table Using Service Broker

Description:
In this first part of the SQL Server Audit Schema Changes series, we dive into how to track DDL (Data Definition Language) changes—such as CREATE, ALTER, and DROP statements—by capturing them and logging to a custom table. Learn how to leverage SQL Server’s native features, including DDL triggers and Service Broker, to build a lightweight, asynchronous auditing solution that doesn’t compromise performance. This post walks you through the architecture, setup, and practical implementation, laying the foundation for robust schema change tracking in your SQL Server environment.

----Store Procs

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

  
/*  
    Description: 
    Return a list of databases associated with DDL events or DDL snapshots in this database 
  
*/ 
CREATE PROC [SchemaAudit].[Databases_Get] 
AS 
SET NOCOUNT ON; 
SELECT DISTINCT DatabaseName 
FROM SchemaAudit.Objects 
ORDER BY DatabaseName;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
Insert XML generated by DDL Event into SchemaAudit.EventData table.  A trigger exists 
on the EventData table to add the event to the SchemaAudit.Event table etc. 
*/ 
CREATE PROC [SchemaAudit].[DDLEvent_Insert](@EventData XML) 
WITH EXECUTE AS OWNER 
AS 
SET NOCOUNT ON; 
INSERT INTO SchemaAudit.EventData(EventDataXML) 
VALUES(@EventData);
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
    Return a list of databases that are tracking DDL Events with event notifications 
    broker_instance specified in event notification is returned so this can be compared to 
    broker_instance of SchemaAudit database.   
  
*/ 
CREATE PROC [SchemaAudit].[DDLEventNotificationDBs_Get] 
AS 
CREATE TABLE #EN( 
    db SYSNAME NOT NULL PRIMARY KEY, 
    broker_instance UNIQUEIDENTIFIER, 
    ErrorMessage NVARCHAR(2048) 
); 
DECLARE @db SYSNAME; 
DECLARE @SQL NVARCHAR(MAX); 
DECLARE cDatabases CURSOR LOCAL FAST_FORWARD 
    FOR SELECT name 
        FROM sys.databases 
        WHERE state=0 --ONLINE 
        AND user_access = 0 --MULTI_USER 
        AND is_read_only = 0 
        AND name NOT IN('master','model','msdb') 
        AND is_broker_enabled = 1; 
          
OPEN cDatabases; 
WHILE 1=1 
BEGIN; 
    FETCH NEXT FROM cDatabases INTO @db; 
    IF @@FETCH_STATUS <> 0 
    BEGIN; 
        BREAK; 
    END; 
    SET @SQL=N'USE ' + QUOTENAME(@db) + ' 
    INSERT INTO #EN(db,broker_instance) 
    SELECT ' + QUOTENAME(@db,'''') + ',broker_instance 
    FROM sys.event_notifications 
    WHERE name = ''SchemaAudit_DDLEvent_Notification'' 
    AND service_name = ''SchemaAudit_DDLEventService'' 
    '; 
    BEGIN TRY; 
        EXEC sp_executesql @SQL; 
    END TRY 
    BEGIN CATCH; 
        --Error might occur if user doesn't have access to the database 
        INSERT INTO #EN(db,ErrorMessage) 
        SELECT @db,ERROR_MESSAGE(); 
    END CATCH; 
END; 
CLOSE cDatabases; 
DEALLOCATE cDatabases; 
  
SELECT db,broker_instance,ErrorMessage 
FROM #EN; 
  
DROP TABLE #EN;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
    Return broker_instance for server level event notification if it exists 
  
*/ 
CREATE PROC  [SchemaAudit].[DDLEventNotificationServer_Get] 
AS 
SELECT broker_instance 
FROM sys.server_event_notifications 
WHERE name = 'SchemaAudit_DDLEvent_Notification' 
AND service_name = 'SchemaAudit_DDLEventService';
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
CREATE PROCEDURE [SchemaAudit].[DDLEventQueue_Receive] 
AS 
SET NOCOUNT ON; 
SET XACT_ABORT ON; 
DECLARE @tDDLEvents TABLE( 
    EventDataXML XML 
); 
 --Keep processing messages until timeout (no more messages to process) 
WHILE 1=1 
BEGIN; 
       -- Transaction includes rcv msgs, process, and write to table 
       BEGIN TRAN; 
       -- Get next 1000 messages from queue.  Timeout after 1 second if no messages are on the queue 
       WAITFOR (RECEIVE TOP(1000) message_body 
                    FROM SchemaAudit.DDLEvent_Queue              
     INTO @tDDLEvents 
                     ), TIMEOUT 1000; 
       -- Exit if not messages returned 
       IF @@ROWCOUNT = 0 
       BEGIN; 
              ROLLBACK TRAN; 
              BREAK; 
       END; 
    -- A trigger will fire on insert into EventData, shredding the XML data and populating various other tables (events, objects etc) 
       INSERT INTO SchemaAudit.EventData(EventDataXML) 
       SELECT EventDataXML 
       FROM @tDDLEvents 
    WHERE EventDataXML IS NOT NULL; 
  
       -- Empty table variable ready to process next batch of events 
       DELETE FROM @tDDLEvents; 
       COMMIT TRAN; 
END;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
    Return the data that was captured for a specific DDL event in it's original XML format 
*/ 
CREATE PROC [SchemaAudit].[EventData_Get]( 
    @EventID INT 
) 
AS 
SET NOCOUNT ON; 
SELECT EventDataXML  
FROM SchemaAudit.EventData 
WHERE EventID = @EventID;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
    Return a list of event types e.g. (CREATE PROCEDURE, ALTER TABLE etc) 
  
*/ 
CREATE PROC [SchemaAudit].[EventTypes_Get]( 
    @ObjectTypes XML=NULL 
) 
AS 
SET NOCOUNT ON; 
DECLARE @tObjectTypes TABLE( 
        ObjectType VARCHAR(50) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL PRIMARY KEY 
); 
IF @ObjectTypes IS NOT NULL 
BEGIN; 
    INSERT INTO @tObjectTypes(ObjectType) 
    SELECT T.c.value('.','VARCHAR(50)') 
    FROM @ObjectTypes.nodes('/items/item') as T(c); 
  
    SELECT DISTINCT EventType  
    FROM SchemaAudit.EventTypes ET 
    JOIN @tObjectTypes OT ON ET.ObjectType = OT.ObjectType 
    ORDER BY EventType; 
END; 
ELSE 
BEGIN 
    SELECT DISTINCT EventType  
    FROM SchemaAudit.EventTypes 
    ORDER BY EventType; 
END;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
    Return a distinct list of Login Names from SchemaAudit.Logins table 
*/ 
CREATE PROC [SchemaAudit].[Logins_Get] 
AS 
SET NOCOUNT ON; 
SELECT DISTINCT LoginName 
FROM SchemaAudit.Logins 
ORDER BY LoginName;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
    Search for a list of object names matching the specified filter.  Used to produce a  
    selection list of object names in the filter dialog 
  
*/ 
CREATE PROC [SchemaAudit].[Objects_Get]( 
    @Databases XML=NULL, 
    @Schemas XML=NULL, 
    @ObjectTypes XML=NULL 
) 
AS 
SET NOCOUNT ON; 
DECLARE @SQL NVARCHAR(MAX); 
DECLARE @NewLine NVARCHAR(MAX); 
SET @NewLine = N' 
'; 
SET @SQL = N' 
DECLARE @tDatabases TABLE( 
    DatabaseName NVARCHAR(128) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL PRIMARY KEY 
); 
DECLARE @tSchemas TABLE( 
    SchemaName NVARCHAR(128) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL PRIMARY KEY 
); 
DECLARE @tObjectTypes TABLE( 
        ObjectType VARCHAR(50) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL PRIMARY KEY 
); 
'; 
IF @Databases IS NOT NULL 
BEGIN; 
    SET @SQL = @SQL + N' 
INSERT INTO @tDatabases(DatabaseName) 
SELECT T.c.value(''.'',''NVARCHAR(128)'') 
FROM @Databases.nodes(''/items/item'') as T(c); 
'; 
END; 
IF @Schemas IS NOT NULL 
BEGIN; 
    SET @SQL = @SQL + N' 
INSERT INTO @tSchemas(SchemaName) 
SELECT T.c.value(''.'',''NVARCHAR(128)'') 
FROM @Schemas.nodes(''/items/item'') as T(c) 
'; 
END; 
IF @ObjectTypes IS NOT NULL 
BEGIN; 
    SET @SQL = @SQL + N' 
INSERT INTO @tObjectTypes(ObjectType) 
SELECT T.c.value(''.'',''VARCHAR(50)'') 
FROM @ObjectTypes.nodes(''/items/item'') as T(c) 
'; 
END; 
  
SET @SQL = @SQL + N' 
SELECT DISTINCT ObjectName  
FROM SchemaAudit.Objects O 
WHERE 1=1 
' 
 + CASE WHEN @Databases IS NOT NULL THEN @NewLine + 'AND EXISTS(SELECT 1 FROM @tDatabases t WHERE t.DatabaseName = O.DatabaseName)' ELSE '' END 
 + CASE WHEN @Schemas IS NOT NULL THEN @NewLine + 'AND EXISTS(SELECT 1 FROM @tSchemas t WHERE t.SchemaName = O.SchemaName)' ELSE '' END 
 + CASE WHEN @ObjectTypes IS NOT NULL THEN @NewLine + 'AND EXISTS(SELECT 1 FROM @tObjectTypes t WHERE t.ObjectType = O.ObjectType)' ELSE '' END + 
'ORDER BY ObjectName'; 
  
EXEC sp_executesql @SQL,N'@Databases XML,@Schemas XML,@ObjectTypes XML',@Databases,@Schemas,@ObjectTypes;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
  
/*  
    Description: 
    Return a list of object types.  Used to produce a  
    selection list of object types in the filter dialog 
  
*/ 
CREATE PROC [SchemaAudit].[ObjectTypes_Get] 
AS 
SET NOCOUNT ON; 
SELECT DISTINCT ObjectType  
FROM SchemaAudit.EventTypes 
ORDER BY ObjectType;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO


 

 /*
 Use if you want email sent out or send to teams
 */
CREATE PROCEDURE [SchemaAudit].[retrieveschema_changeinfo]
@MIN INT -- NUMBER OF MINUTES YOU WANT TO GO BACK AND CHECK FOR CHANGES
AS
 
SET QUOTED_IDENTIFIER ON
 
    DECLARE 
        @RecipientEmailAddress NVARCHAR(2048),
        @EmailSubject NVARCHAR(1024),
        @LAST_RUN_DATE datetime,
        @tableHTML  NVARCHAR(MAX),
        @Style  NVARCHAR(MAX),
        @ServerName sysname,
        @Subject_msg VARCHAR(8000),
		@Subject_msgbody VARCHAR(8000),
        @MsgFilterList VARCHAR(8000),
        @UseNotLike BIT = 1,
        @UseOr BIT = 0,
        @SQL VARCHAR(8000),
        @Body VARCHAR(MAX),
        @message VARCHAR(256),
        @dbmail_body NVARCHAR(MAX),
        @DBMailProfileName varchar(100),
        @dbmail_recipients varchar(100)
     

				   
            Select @DBMailProfileName=name
             from msdb.dbo.sysmail_profile 
 
    SET @RecipientEmailAddress = 'email'; 
    SET @ServerName = @@SERVERNAME;   
    SET @Subject_msg = 'SchemaAudit have Occurred on ' + @ServerName; 
    SET @DBMailProfileName = 'name'; 
   --- SET @dbmail_recipients = 'email'; 

 
 
CREATE TABLE #SchemaAudit
            (ServerName sysname,
            DatabaseName sysname,
            SchemaName varchar(256),
            ChangeType varchar(256),
            ObjectName varchar(256),
            ChangerloginName varchar(256),
            ModifiedDate datetime)
                 
 
SELECT  @MsgFilterList = ''
 
--1st pass
 SELECT  @MsgFilterList = @MsgFilterList + CHAR(10)+ CHAR(13) + 
                        CASE WHEN @UseOr = 0 THEN 'AND ' ELSE 'OR ' END + 'CommandText ' + 
                        CASE WHEN @UseNotLike = 1 THEN 'NOT LIKE ''' ELSE 'LIKE ''' END + Exclude_TSQL_Command_Pattern + ''''
 FROM SchemaAudit.ExcludedTSQLs as lf
 
 --2nd pass
 
  SELECT  @MsgFilterList = @MsgFilterList + CHAR(10)+ CHAR(13) + 
                        CASE WHEN @UseOr = 0 THEN 'AND ' ELSE 'OR ' END + 'ChangerloginName ' + 
                        CASE WHEN @UseNotLike = 1 THEN 'NOT LIKE ''' ELSE 'LIKE ''' END + LoginName + ''''
 FROM SchemaAudit.ExcludeLogins 
 
 
SELECT @MsgFilterList = SUBSTRING(@MsgFilterList, 6, DATALENGTH(@MsgFilterList))
SELECT  @MsgFilterList = @MsgFilterList + CHAR(10)+ CHAR(13) + 'AND ModifiedDate >= DATEADD(MINUTE, -' + CONVERT(varchar(15), @MIN) + ', CURRENT_TIMESTAMP)'  
 
 
 
SET @SQL = 'INSERT INTO #SchemaAudit (ServerName,
DatabaseName,
SchemaName,
ChangeType,
ObjectName,
ChangerloginName,
ModifiedDate)
 SELECT [ServerName]
      ,[DatabaseName]
      ,[SchemaName]
      ,[ChangeType]
      ,[ObjectName]
      ,[ChangerloginName]
      ,[ModifiedDate]
FROM    SchemaAudit.[Retrieve_Schema_Change_Info] t
 WHERE ' + @MsgFilterList
 
--SELECT @SQL 
 
EXEC (@SQL)
 
IF (SELECT COUNT(*) 
FROM #SchemaAudit) > 0
 
BEGIN
        SET @tableHTML = 
        N'<style type="text/css">
        #box-table
        {
        font-family: "Lucida Sans Unicode", "Lucida Grande", Sans-Serif;
        table width=100%;
        font-size: 12px;
        text-align: left;
        border-collapse: collapse;
        border-top: 5px solid #267EAE;
        border-bottom: 5px solid #267EAE;
        }
        #box-table th
        {
        table width=100%;
        font-size: 12px;
        font-weight: normal;
        background: IndianRed;
        border-right: 1px solid #267EAE;
        border-left: 1px solid #267EAE;
        border-bottom: 1px solid #267EAE;
        color: Black;
        }
        #box-table tr
        {
        table width=100%;
        font-size: 13px;
        font-weight: normal;
        background: Lavender;
        border-right: 1px solid #267EAE;
        border-left: 1px solid #267EAE;
        border-bottom: 1px solid #267EAE;
        color: #003399;
        }
        #box-table td
        {
        table width=100%;
        border-right: 1px solid #267EAE;
        border-left: 1px solid #267EAE;
        border-bottom: 1px solid #267EAE;
        color: Black;
        }
 
        tr:nth-child(odd)   { background-color:#000000; }
        tr:nth-child(even)  { background-color:#ffffff; }
 
        </style>'+
        N'<table id="box-table" >' +
        N'
        <td><table width=100%><font face=Lucida Sans Unicode color=#003399 size=4><strong>'+@Subject_msg+'</strong></font></td
        </style>'+    
        N'<table id="box-table" >' +
        N'
        <th>ServerName</th>
        <th>DatabaseName</th>
        <th>SchemaName</th>
        <th>ChangeType</th>
        <th>ObjectName</th>
        <th>ChangerloginName</th>
        <th>ModifiedDate</th>'+
            CAST ( ( SELECT td = ServerName,       '',
                            td = DatabaseName, '',
                            td = SchemaName, '',
                            td = ChangeType, '',
                            td = ObjectName, '',
                            td = ChangerloginName, '',
                            td = ModifiedDate, ''
 
                      FROM  #SchemaAudit
                      ORDER BY ModifiedDate DESC
                   FOR XML PATH('tr'), TYPE 
            ) AS NVARCHAR(MAX) ) +
            N'</table>' ;
 
 
            SET @tableHTML = REPLACE(REPLACE(@tableHTML, '&lt;', '<'), '&gt;', '>')+ '</table>'
    --SET @dbmail_body = @message + @tableHTML
     
            EXEC msdb.dbo.sp_send_dbmail 
            @profile_name =@DBMailProfileName,
            @recipients= @RecipientEmailAddress,
			--@blind_copy_recipients='enteremail',
            @subject = @Subject_msg,
            @body = @tableHTML,
            @body_format = 'HTML' ;
 
DROP TABLE #SchemaAudit
END

GO

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
/*  
    Description: 
    Return a list of schemas.  Used to produce a  
    selection list of schemas in the filter dialog 
  
*/ 
CREATE PROC [SchemaAudit].[Schemas_Get]( 
    @Databases XML=NULL 
) 
AS 
SET NOCOUNT ON; 
DECLARE @SQL NVARCHAR(MAX); 
DECLARE @NewLine NVARCHAR(MAX); 
SET @NewLine = N' 
'; 
SET @SQL = N' 
DECLARE @tDatabases TABLE( 
    DatabaseName NVARCHAR(128) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL PRIMARY KEY 
) 
'; 
IF @Databases IS NOT NULL 
BEGIN; 
    SET @SQL = @SQL + N' 
INSERT INTO @tDatabases(DatabaseName) 
SELECT T.c.value(''.'',''NVARCHAR(128)'') 
FROM @Databases.nodes(''/items/item'') as T(c) 
'; 
END; 
  
SET @SQL = @SQL + N' 
SELECT DISTINCT SchemaName  
FROM SchemaAudit.Objects O 
WHERE 1=1 
' + CASE WHEN @Databases IS NOT NULL THEN @NewLine + 'AND EXISTS(SELECT 1 FROM @tDatabases t WHERE t.DatabaseName = O.DatabaseName)' ELSE '' END + ' 
ORDER BY SchemaName;'; 
  
EXEC sp_executesql @SQL,N'@Databases XML',@Databases;
GO


SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO



  
/*  
    Description: 
    Return a list of server names associated with DDL operations or DDL snapshots that exist within this database 
    Normally this would just return the name of the current database instance 
  
*/ 
CREATE PROC [SchemaAudit].[Servers_Get] 
AS 
SET NOCOUNT ON; 
SELECT DISTINCT ServerName 
FROM SchemaAudit.Objects 
ORDER BY ServerName;
GO


Discover more from SQLYARD

Subscribe to get the latest posts sent to your email.

Leave a Reply

Discover more from SQLYARD

Subscribe now to keep reading and get access to the full archive.

Continue reading