Friday, November 2, 2012

Keeping an Eye on Server Side Traces in SQL Server


Just a quick snippet, thought I'd share a method of keeping an eye on server-side traces.

When you've set up your SST and it's running successfully, you will often use something like:

SELECT * FROM sys.traces WHERE id = [your trace id];

This renders a whole load of useful information - id, path, stop time, etc.  But there's a couple of noticeable features which could be handy.  Sometimes I'd like to know a) how long the trace has left to run and b) how large the file size will get.

We can work out these additional two parameters with some simple mathematics.

So, first - how long left.  Well, we need to work out the difference between the current time and the planned end time, and display the difference.  Nice and easy.

[SELECT...] DATEDIFF ( minute, GETDATE(), stop_time ) [minutes_remaining]

The second requirement is a little trickier.  We want to know how large the file size will get.  We can work this out dynamically by measuring a) how much time has passed so far since the trace was started, b) how large the file size is now.  We then divide b) by a) to get an estimation of file growth per minute.  We then multiply by the number of minutes the trace will run for.

Note this method isn't foolproof since for a start the file growth happens in intervals, not continuously, and that file growth won't be linear under a skewed load.  However, it's reasonably accurate.

First, work out how much time has passed in minutes, CAST to FLOAT for greater accuracy.

CAST(( DATEDIFF (minute, start_time, stop_time ) - DATEDIFF ( minute, GETDATE(), stop_time ) AS FLOAT )

Now include the division:

file_position / CAST(( DATEDIFF (minute, start_time, stop_time ) - DATEDIFF ( minute, GETDATE(), stop_time ) AS FLOAT )

This is the estimated growth per minute.

Now multiply by the number of minutes in the trace interval (difference between start and end times):


file_position / CAST(( DATEDIFF (minute, start_time, stop_time ) - DATEDIFF ( minute, GETDATE(), stop_time ) AS FLOAT ) *  DATEDIFF (minute, start_time, stop_time )

Now round off to 2 d.p for cosmetic neatness:

ROUND((file_position / CAST(( DATEDIFF (minute, start_time, stop_time ) - DATEDIFF ( minute, GETDATE(), stop_time ) AS FLOAT ) *  DATEDIFF (minute, start_time, stop_time )),2) 

Now alias it for a column name, combine with our earlier SELECT query for the minutes remaining, and some other useful info from the sys.traces view:

SELECT  id, path, stop_time, file_position, last_event_time, event_count, 
                DATEDIFF ( minute, GETDATE(), stop_time ) [minutes_remaining],
                ROUND((file_position / 
                    CAST(( DATEDIFF minute, start_time, stop_time ) 
                    -  DATEDIFF ( minute, GETDATE(), stop_time )) AS FLOAT )
                    *  DATEDIFF (minute, start_time, stop_time )),2)
                    [forecast_final_size]
FROM    sys.traces
WHERE   id = -- insert your id # here

Which yields a result like the following:



As you can see, this gives us extra, useful information that's easy to refresh with F5 (or put into a scheduled job and output elsewhere, or similar) that can allow us to avoid problems like runaway trace file sizes - and stop us forgetting about traces.




Thursday, October 18, 2012

Scripting CREATE INDEX Statements Automatically From Your Table Indexes


Just thought I'd share a script I wrote recently to get full CREATE INDEX statements (including filters, filegroup information and all the WITH options) from existing indexes you have.  This is useful when duplicating tables, e.g. when you use SELECT * INTO... you'll find you leave your NC indexes behind.  Or if you're dropping some indexes and want to be able to recreate them in a hurry.

When I Googled for this I couldn't find a single satisfactory solution to this problem, so here's mine.  Tested OK, read all the comments and if you want to test it for yourself, you'll find some test code to create sample objects at the end of the code segment.

*  NOTE:  COPY AND PASTE THE CODE BELOW INTO A NOTEPAD WINDOW / SSMS QUERY WINDOW TO RENDER THE LINE BREAKS PROPERLY.  APOLOGIES FOR BLOGGER'S POOR CODE RENDERING SUPPORT :-S



dbo.recreateIndexes ( @schema_name VARCHAR(100) = NULL, @table_name VARCHAR(MAX) = NULL, @sort_in_tempdb BIT = 0, @statistics_norecompute BIT = 0, @drop_existing BIT = 0, @online BIT = 0, @maxdop TINYINT = 1, @data_compression BIT = 0, @data_compression_type VARCHAR(4) = 'NONE' ) AS BEGIN /* Title: recreateIndexes Summary: Procedure to script out full CREATE INDEX statements for non-clustered indexes. Author: Derek Colley, derek@derekcolley.co.uk, blog: http://uksqldba.blogspot.com Date: 18/10/2012 --- Parameters: @schema_name VARCHAR(100) -- the schema name of the table to find indexes for, optional. @table_name VARCHAR(MAX) -- the table name of the table to find indexes for, optional. @sort_in_tempdb BIT = 0 -- defines whether or not to create the index using TEMPDB @statistics_norecompute BIT = 0 -- defines whether to UPDATE STATISTICS after index creation @drop_existing BIT = 0 -- specifies whether to drop an existing index of the same name @online BIT = 0 -- specifies whether to do the operation online (unavailable sub-Enterprise edition) @maxdop TINYINT = 1 -- max degree of parallelism, play with this at your peril @data_compression BIT = 0 -- whether you want data compression on your indexes @data_compression_type VARCHAR(4) = 'NONE' -- valid parameters are 'NONE', 'PAGE', 'ROW'. Set @data_compression = 1 too. Behaviour: Returns full CREATE INDEX statements to console (Messages tab). Limitations: 1) Although some WITH parameters are drawn from sys.indexes, the procedure parameters are set in stone for all indexes. 2) I'm using a cursor :-( 3) Limited to NONCLUSTERED indexes only at present. To be fair your CLUSTERED index should be scripted at CREATE TABLE time. Misc: 1) I have included some commented-out test code you can use to create some indexes to test this procedure on. 2) I've not included functionality to pick a database or (linked) server, simple enough so mod if you like. 3) Use this procedure at your own risk, ALWAYS test on your test/QA BEFORE deploying to production. 4) If you find any errors or can improve upon this code, tell me! E-mail address above. 5) Tested on SQL Server Std Ed 2008 SP2 successfully. */ IF EXISTS (SELECT * FROM [tempdb].[sys].[objects] WHERE [name] LIKE '#indexProperties%') DROP TABLE #indexProperties SELECT SCHEMA_NAME(t.[schema_id]) [schema_name], t.[name] [table_name], i.[name] [index_name], c.[name] [column_name], ic.key_ordinal [column_position], i.[type_desc] [index_type], i.fill_factor [fill_factor], i.is_padded [is_padded], i.is_disabled [is_disabled], i.[allow_row_locks] [allow_row_locks], i.[allow_page_locks] [allow_page_locks], i.has_filter [has_filter], i.filter_definition [filter_definition], ic.is_included_column [is_included], ic.is_descending_key [is_descending_key], i.[ignore_dup_key] [ignore_dup_key], i.data_space_id [data_space_id] INTO #indexProperties FROM sys.indexes i INNER JOIN sys.tables t ON i.[object_id] = t.[object_id] INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id INNER JOIN sys.columns c ON t.object_id = c.object_id AND ic.column_id = c.column_id WHERE i.[name] IS NOT NULL AND i.type_desc = 'NONCLUSTERED' AND SCHEMA_NAME(t.[schema_id]) = CASE WHEN @schema_name IS NULL THEN SCHEMA_NAME(t.[schema_id]) ELSE @schema_name END AND t.[name] = CASE WHEN @table_name IS NULL THEN t.[name] ELSE @table_name END ORDER BY i.[name], ic.index_column_id ASC DECLARE @schemaName VARCHAR(MAX), @tableName VARCHAR(MAX), @indexName VARCHAR(MAX) DECLARE @colList VARCHAR(MAX), @includedColList VARCHAR(MAX), @withOptions VARCHAR(MAX) DECLARE @fillFactor INT, @is_padded BIT, @is_disabled BIT, @allow_page_locks BIT, @allow_row_locks BIT DECLARE @is_filtered BIT, @filterDefinition VARCHAR(MAX), @ignoredupkey BIT, @filegroup VARCHAR(100) DECLARE cur_ForEachIndex CURSOR LOCAL FAST_FORWARD FOR SELECT DISTINCT ip.[schema_name], ip.[table_name], ip.[index_name] FROM #indexProperties ip OPEN cur_ForEachIndex FETCH NEXT FROM cur_ForEachIndex INTO @schemaName, @tableName, @indexName WHILE @@FETCH_STATUS = 0 BEGIN -- aggregate the main columns SELECT @colList = ISNULL(@colList,'') + ip.[column_name] + ', ' FROM #indexProperties ip WHERE ip.[schema_name] = @schemaName AND ip.[table_name] = @tableName AND ip.index_name = @indexName AND ip.is_included = 0 ORDER BY ip.column_position ASC -- trim the trailing comma SET @colList = LEFT(@colList,(LEN(@colList) - 1)) -- aggregate the included columns SELECT @includedColList = ISNULL(@includedColList,'') + ip.[column_name] + ', ' FROM #indexProperties ip WHERE ip.[schema_name] = @schemaName AND ip.[table_name] = @tableName AND ip.index_name = @indexName AND ip.is_included = 1 -- trim the trailing comma SET @includedColList = LEFT(@includedColList,(LEN(@includedColList) - 1 )) -- now get special options per index SELECT @fillFactor = ip.fill_factor, @is_padded = ip.is_padded, @is_disabled = ip.is_disabled, @allow_row_locks = ip.[allow_row_locks], @allow_page_locks = ip.[allow_page_locks], @is_filtered = ip.[has_filter], @filterDefinition = ip.[filter_definition], @ignoredupkey = ip.[ignore_dup_key], @filegroup = fg.[name] FROM #indexProperties ip LEFT JOIN sys.filegroups fg ON ip.data_space_id = fg.data_space_id WHERE ip.[schema_name] = @schemaName AND ip.[table_name] = @tableName AND ip.index_name = @indexName -- deliver an output PRINT 'CREATE NONCLUSTERED INDEX [' + @indexName + '] ON [' + @schemaName + '].[' + @tableName + '] (' + @colList + ') ' + CASE WHEN @includedColList IS NOT NULL THEN ' INCLUDE (' + @includedColList + ') ' ELSE '' END + CASE WHEN @is_filtered = 1 THEN ' WHERE ( ' + @filterDefinition + ' ) ' ELSE '' END + ' WITH ( PAD_INDEX = ' + CASE WHEN @is_padded = 1 THEN 'ON,' ELSE 'OFF,' END + ' FILLFACTOR = ' + CASE WHEN @fillfactor = 0 THEN '100' ELSE CAST((ISNULL(CAST(@fillfactor AS INT),100)) AS VARCHAR(3)) END + ', ' + ' IGNORE_DUP_KEY = ' + CASE WHEN @ignoredupkey = 1 THEN 'ON,' ELSE 'OFF,' END + ' ALLOW_ROW_LOCKS = ' + CASE WHEN @allow_row_locks = 1 THEN 'ON,' ELSE 'OFF,' END + ' ALLOW_PAGE_LOCKS = ' + CASE WHEN @allow_page_locks = 1 THEN 'ON,' ELSE 'OFF,' END + ' SORT_IN_TEMPDB = ' + CASE WHEN @sort_in_tempdb = 1 THEN 'ON,' ELSE 'OFF,' END + ' STATISTICS_NORECOMPUTE = ' + CASE WHEN @statistics_norecompute = 1 THEN 'ON,' ELSE 'OFF,' END + ' DROP_EXISTING = ' + CASE WHEN @drop_existing = 1 THEN 'ON,' ELSE 'OFF,' END + ' ONLINE = ' + CASE WHEN @online = 1 THEN 'ON,' ELSE 'OFF,' END + ' MAXDOP = ' + CAST((ISNULL(CAST(@maxdop AS INT),0)) AS VARCHAR(1)) + ', ' + ' DATA_COMPRESSION = ' + CASE WHEN @data_compression = 1 THEN QUOTENAME(@data_compression_type,'''') ELSE 'NONE' END + ' );' -- reset and increment SET @colList = NULL SET @includedColList = NULL FETCH NEXT FROM cur_ForEachIndex INTO @schemaName, @tableName, @indexName END ---- BEGINNING OF TEST CODE --CREATE SCHEMA test --GO --CREATE TABLE test.TableWithIndexes ( -- productId INT PRIMARY KEY NOT NULL, -- productName VARCHAR(100) NOT NULL, -- colour VARCHAR(20) NULL, -- dateAvailable DATE NULL, -- price MONEY NULL, -- discontinued BIT DEFAULT 0 NULL -- ) --INSERT INTO test.TableWithIndexes --VALUES (1, 'Defrabuliser', 'Blue', '2009-04-30', 19.99, 0), -- (15, 'Unbobulator', 'Green', '2012-01-13', 85.00, 0), -- (22, 'Disbibulator', NULL, '2012-11-18', 12.50, 0), -- (89, 'Bishbosher', 'Orange', '2008-05-23', 109.99, 1), -- (101, 'Jambasher', 'Yellow', '2001-03-03', 3.99, 0) ---- create PK/clustered index with padding/fill factor --ALTER TABLE test.TableWithIndexes ADD CONSTRAINT ix_pk_productId PRIMARY KEY CLUSTERED (productId) --WITH (PAD_INDEX = ON, FILLFACTOR=90) ---- create non-clustered non-covering indexes with single column --CREATE INDEX ix_nc_productName ON test.TableWithIndexes (productName) --CREATE INDEX ix_nc_dateAvailable ON test.TableWithIndexes (dateAvailable) ---- create non-clustered non-covering index with multiple columns --CREATE INDEX ix_nc_productName_price ON test.TableWithIndexes (productName, price) ---- create non-clustered covering index with multiple columns and INCLUDEs --CREATE INDEX ix_nc_productId_productName_dateAvailable_iPrice_iDiscontinued ON test.TableWithIndexes (productId, productName, dateAvailable) --INCLUDE ( price, discontinued ) ---- create non-clustered filtered covering index with an INCLUDE --CREATE INDEX ix_nc_productId_iPrice_filtered ON test.TableWithIndexes ( productId ) INCLUDE ( price ) WHERE ( price < 10 ) ---- execute the procedure --EXEC dbo.recreateIndexes ---- now test the indexes work! --DROP INDEX [ix_nc_dateAvailable] ON test.TableWithIndexes --DROP INDEX [ix_nc_productId_iPrice_filtered] ON test.TableWithIndexes --DROP INDEX [ix_nc_productId_productName_dateAvailable_iPrice_iDiscontinued] ON test.TableWithIndexes --DROP INDEX [ix_nc_productName] ON test.TableWithIndexes --DROP INDEX [ix_nc_productName_price] ON test.TableWithIndexes ---- pasted from Messages tab --CREATE NONCLUSTERED INDEX [ix_nc_dateAvailable] ON [test].[TableWithIndexes] (dateAvailable) WITH ( PAD_INDEX = OFF, FILLFACTOR = 100, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, SORT_IN_TEMPDB = OFF, STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, MAXDOP = 1, DATA_COMPRESSION = NONE ); --CREATE NONCLUSTERED INDEX [ix_nc_productId_iPrice_filtered] ON [test].[TableWithIndexes] (productId) INCLUDE (price) WHERE ( ([price]<(10)) ) WITH ( PAD_INDEX = OFF, FILLFACTOR = 100, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, SORT_IN_TEMPDB = OFF, STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, MAXDOP = 1, DATA_COMPRESSION = NONE ); --CREATE NONCLUSTERED INDEX [ix_nc_productId_productName_dateAvailable_iPrice_iDiscontinued] ON [test].[TableWithIndexes] (productId, productName, dateAvailable) INCLUDE (price, discontinued) WITH ( PAD_INDEX = OFF, FILLFACTOR = 100, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, SORT_IN_TEMPDB = OFF, STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, MAXDOP = 1, DATA_COMPRESSION = NONE ); --CREATE NONCLUSTERED INDEX [ix_nc_productName] ON [test].[TableWithIndexes] (productName) WITH ( PAD_INDEX = OFF, FILLFACTOR = 100, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, SORT_IN_TEMPDB = OFF, STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, MAXDOP = 1, DATA_COMPRESSION = NONE ); --CREATE NONCLUSTERED INDEX [ix_nc_productName_price] ON [test].[TableWithIndexes] (productName, price) WITH ( PAD_INDEX = OFF, FILLFACTOR = 100, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, SORT_IN_TEMPDB = OFF, STATISTICS_NORECOMPUTE = OFF, DROP_EXISTING = OFF, ONLINE = OFF, MAXDOP = 1, DATA_COMPRESSION = NONE ); -- END OF TEST CODE END

Monday, September 10, 2012

SQL Saturday #162 - Cambridge, UK


SQL Saturday #162 - Cambridge, UK


After a perilous drive across the foggy moors of the Pennines, and a nice, straight run down the Great North Road, I arrived in Cambridge at 07:45 on Saturday morning and - wow, what a crowd!

Being my first SQL Saturday, I was a bit ignorant of the drill, so waded right in with my SpeedPASS admission and raffle tickets, and went and found some familiar faces.

The sponsors were about doing their bit - Fusion IO had the most notable stall, with a $16,000 PCI-e Flash memory card available to take a look at, and a live demonstration of the speed increase of Fusion IO kit (log flushes measured in GB/sec!).  I'm definitely sold, although at the current high price of Fusion IO kit, my manager might not yet be.  Still, I can see it replacing the SAN, with long-term durability and the capability to fit many more GB/cm3 than currently possible with rotational drives.

Red Gate were out in force too, with an army of red-clad salespeople with leaflets and live demos of their SQL Monitor and SQL Storage Compress technologies.  I'm not the biggest fan of Red Gate products, finding that some are excellent (SQL Prompt, Data Generator, Search in particular) and some are just not right for me - SQL Backup (2 licenses required to restore to a secondary server - nice to find that out at 2am) and SQL Monitor (too simple, not enough depth or breadth).

SQL Sentry provided the best demo for me - the sheer amount of complex information available from one screen that will enable me to make the correct decisions in a disaster scenario (or simply check the health of my databases in a glance) was astounding.  Their software has features that would take me weeks to code up individually.  Note to Red Gate.  I like complexity!  Give me complexity!  The K.I.S.S. principle is NOT for me!

The sessions were great, too.  I started with Neil Hambly's session on Extended Events, which I've read a little about before but had trouble getting to grips with some of the concepts.  Neil explained it well, unfortunately my core environment is 2005 Standard (temporarily) but I look forward to applying some of his tips.  Buck Woody followed Neil's session with a well-delivered and uplifting keynote speech, with a brief history of SQL Server (tip: did you know that the silhouette icon in Outlook is modelled on the mugshot of Bill Gates as a teenager?).

Next up for me was Tobiasz Koprowski, an engaging Polish DBA with a great session on disaster recovery.  He spoke from experience, detailing the steps you need to take in an emergency (be prepared, basically), and including tips on the things you would perhaps never normally think about.  How to open a server rack when you don't have the keys.  Who to get out of bed when your server falls over at 2am.  Why hand warmers are important in a server room. 

I followed this with Niko Neugeberger's talk on inheriting a database for developers.  I was particularly interested in this, since I have also inherited a large number of databases from developers with a limited amount of DBA participation before I joined.  He spoke about improving performance, about checking how tables have been built and maintained, and although geared at developers the talk was very relevant to my work as a DBA, detailing for example the importance of keeping statistics updated on tables and about index fragmentation.

Straight afterwards was Martin Cairns session on Partitioning Design for Performance and Maintainability.  Martin gave an overview of partitioning and explained at length about how to design partition functions and schemas, and how to improve performance by using techniques such as partition elimination (similar to Denali's COLUMNSTORE index using segment elimination) and the different types (horizontal, vertical and filegroup) of partitioning (and when to use them).  What Martin lacked in engagement he made up for with content, as I scribbled down 3 or 4 pages of useful notes during his talk.

Straight after, I delved into the Fusion IO sponsor session, where I found out the difference between Fusion IO cards and SSDs (there IS a difference!) and just how quickly this new technology works.  

After the sponsor session, I had to stop for lunch - information overload.  The Crowne Plaza had provided some bagged lunch, so I took it outside and got chatting to a few people.  

After lunch, another marathon session.  I went for Hugo Kornelis' session on Making Your Queries Fly with COLUMNSTORE Indexes - this was my first introduction to the 2012 index type and I was extremely impressed.  Hugo had set up working demos, with performance gains demonstrated of 75x the speed of ordinary clustered indexes.  This was a very popular session, with the room hitting maximum occupancy.

Back in to Tobiasz Koprowski's licensing session (of relevance to me, since I recently had to navigate the murky waters of re-licensing under the 2012 per-core model) then onto Phil Quinn's session on XML.  Having had a bad experience with the XQuery features of 2005/08 recently (XML queries blackboxing as 'Remote Query' in the execution plan, leading to unacceptable delays during shredding) I was keen to get his views.  Phil went through 4 or 5 methods of using XML effectively in SQL Server, and referenced the very performance problem I had noticed.  Very helpful session with lots of reference material to take away and a big thank you to him for his time afterwards, patiently answering my XML questions.

After Phil, straight into Mark Broadbent's session (he is the SQL Cambs PASS chapter leader / user group organiser) on READPAST and the true atomicity (as per ACID) of SQL Server.  He went through several demos of where SQL Server does NOT treat transactions atomically (i.e. 'all or nothing') and despite some good-natured heckling from the back (Hugo!) he stepped through an innovative method of processing bucketised data using READPAST, as an alternative to READ COMMITTED / SERIALIZABLE-based locks, to ensure clean data without waits.

After this last session was the prize draw, where some lucky soul won both the OCZ card from Fusion IO AND an Amazon Kindle (fix!) and we heard again from Buck Woody and the organisers.  I left with three new books, a ton of other swag (laptop stickers, stress balls, cups, you name it) and the firm resolve to go again - an excellent experience that I'd recommend to anyone.

Tuesday, September 4, 2012

Auditing Stored Procedure Use in 2005


Auditing Stored Procedure Use in 2005

TL/DR: Generate your own list of extinct stored procedures, or create a frequency map of stored procedure calls.


Another blog post and a long delay since my last one, so my apologies to regular readers (if I have any, of course! :-) ).  If this isn’t your first visit, I hope you found value in my previous posts.  Now, I’ve dreamt up something else.  Today’s topic is how to get a list of stored procedures in your database that simply aren’t used any more (or conversely, are used, and you need a frequency map of how often, by whom, etc.)

Some background: My current shop has around 3000 stored procedures spread over two dozen databases.  I inherited most of these, and they came with no documentation or description of any kind.  Formatting, comments, structured development, even naming conventions were largely absent.  Sadly, nobody knew what ran what – or, more accurately, those who did, left.

So I created the following which assisted me in identifying those stored procedures in my architecture that were simply no longer used.  That, to paraphrase John Cleese, were ex-stored procedures.  They were no more.  They were, in fact, extinct.

Follow and modify the steps below to suit you.  If you have any suggestions for improvement, I’d be glad to hear them – a day where I don’t learn something new is a day wasted.


Step 1:  Set up the server-side trace. Define the trace as follows, or use Profiler and create your own.  You’ll need to capture all SP: Starting and RPC: Starting events, with TextData and DatabaseName columns as a minimum.  I’ve got some extra columns here so I can mess with the data at a later date.

-- Create a Queue
declare @rc int
declare @TraceID int
declare @maxfilesize bigint
set @maxfilesize = 50000

exec @rc =
sp_trace_create @TraceID output, 0, N'<output_path>\file', @maxfilesize, NULL
if (@rc != 0) goto error

-- Set the events
declare @on bit
set @on = 1
exec sp_trace_setevent @TraceID, 11, 1, @on
exec sp_trace_setevent @TraceID, 11, 14, @on
exec sp_trace_setevent @TraceID, 11, 35, @on
exec sp_trace_setevent @TraceID, 11, 12, @on
exec sp_trace_setevent @TraceID, 11, 60, @on
exec sp_trace_setevent @TraceID, 42, 1, @on
exec sp_trace_setevent @TraceID, 42, 14, @on
exec sp_trace_setevent @TraceID, 42, 35, @on
exec sp_trace_setevent @TraceID, 42, 12, @on
exec sp_trace_setevent @TraceID, 42, 60, @on


-- Set the Filters
declare @intfilter int
declare @bigintfilter bigint

-- display trace id for future references
select TraceID=@TraceID
goto finish

error:
select ErrorCode=@rc

finish:
go





Step 2:  When ready, start the trace.

exec sp_trace_setstatus @TraceID, 1




Step 3:  Collect the trace information for as long as you wish.  I recommend 24 hours as a minimum.  To get a true picture of SP usage, you would run this trace for around a week, uninterrupted, to capture all scheduled jobs in that time-frame.  Be careful that you know about jobs with a longer interval, and that the planned run doesn’t coincide with other affecting work, such as planned downtime.
The size of the output of the trace is proportional to the SP call load on the database, so plan for enough disk space.  For me, 24 hours of tracing an average of 210 RPC and local SP calls/sec resulted in 23GB of trace file.




Step 4:  You can monitor what’s going on in sys.traces by querying any column.  However I’ve used a bit of mathematics to make the output more meaningful.  E.g.

DECLARE @starttime DATETIME, @now DATETIME, @delta BIGINT
SET @starttime = ( SELECT start_time FROM sys.traces WHERE status = 1 )
SET @now = GETDATE()
SET @delta = DATEDIFF(ss,@starttime,@now)
select      st.start_time,
            @now [time_now],
            st.file_position, st.event_count, @delta [seconds_elapsed],
            ((24 * 60 * 60) - @delta ) [seconds_left],
            CAST((CAST(ROUND((CAST([file_position] AS DECIMAL(18,2)) / CAST(@delta AS DECIMAL(18,2))),2) AS DECIMAL(18,2)) / 1024768 * 60) AS DECIMAL(18,4)) [MB_per_min],
            CAST((CAST(ROUND((CAST([file_position] AS DECIMAL(18,2)) / CAST(@delta AS DECIMAL(18,2))),2) AS DECIMAL(18,2)) / 1024768 * 60 * 60) AS DECIMAL(18,4)) [MB/hr],
            CAST((CAST(event_count AS DECIMAL(18,2)) / CAST(@delta AS DECIMAL(18,2))) AS DECIMAL(18,2)) [avg_events_per_sec],
            CAST((CAST(@delta AS DECIMAL(18,2)) / ((24 * 60 * 60)) * 100) AS DECIMAL(18,3)) [trace_%_complete],
            (CAST((CAST(ROUND((CAST([file_position] AS DECIMAL(18,2)) / CAST(@delta AS DECIMAL(18,2))),2) AS DECIMAL(18,2)) / 1024768 * 60) AS DECIMAL(18,4)) * 60 * 24) [est_total_file_size_MB]
           

from sys.traces st

This code can be simplified – I think I’ve gotten a bit CAST-happy – so feel free to amend to suit your purpose.  This will output useful columns, such as the # of events so far, the seconds elapsed since starting, the seconds to go to a predefined target, % complete, and a forecast of how much disk space will be required (relies on reasonably linear load pattern).




Step 5:  When you’re finished, stop your trace and remove the server-side definition.

exec sp_trace_setstatus @id = 2, @status = 2
exec sp_trace_setstatus @id = 2, @status = 0




Step 6:  Now, you need to put your trace information into a table, so it can be queried.  This is straightforward.  My work setup is (sadly) 2005 Standard Edition at the moment, but if you’re on 2008+ then use Extended Events rather than fn_trace_gettable, since the latter is deprecated.

CREATE TABLE SANDBOX.dbo.spAuditTraceData ( DatabaseName NVARCHAR(MAX), TextData NVARCHAR(MAX) )

INSERT INTO SANDBOX.dbo.spAuditTraceData
      SELECT DatabaseName, TextData
      FROM fn_trace_gettable('F:\del\sp_audit_output.trc',1)     

(Note: this takes a while.  One useful method I’ve found of finding out how long is to use perfmon and measure an average of the Disk Write Bytes/sec counter.  Then knock off a reasonable amount depending on how busy your server is (hopefully you’re doing this on a quiet dev box).  Then take your total file size (your trace output file) and divide this by your average writes.  Then divide again by 60.  The result is how many minutes you can expect to wait for this step to complete.  For me (23GB) – about 40 minutes (yes, I have high I/O contention and a shitty SAN).




Step 7:  Now you need to create your procedure to compare your captured trace data with the stored procedures in your database, and count up how many times each procedure was executed.  Here’s my take on doing this.  I’ve included the SP header so you know how it works.  Hopefully, you’ll be able to modify and improve upon it, particularly as I’m using two nested CURSORs (for the many-to-many comparisons), which is hardly efficient.  However, like my aging rustbucket of a Ford Escort, it works.

In the code below, I’m using the database ‘SANDBOX_DEV’ and tweaked the parameters to suit the environment.  You will need to do the same.

ALTER PROCEDURE dbo.FindDeadSprocs
      ( @truncateExistingData BIT = 1, @database_name VARCHAR(100) )
AS BEGIN

--------------------------------------------------------------------------------
--  SP:     SANDBOX_DEV.dbo.FindDeadSprocs
--  Author: Derek Colley
--  Updated:      04/09/2012 by Derek Colley.
--
--  Purpose:      Will return the count of times a sproc has been called.
--                      Separates out into two output tables.
--                      Useful for identifying extinct procedures in a database.
--
--  Inputs:       Trace data loaded into SANDBOX_DEV.dbo.spAuditTraceData
--                      in format ( [database_id] BIGINT, [text_data] VARCHAR(MAX) )
--
--  Outputs:      SANDBOX_DEV.dbo.deadSPs
--                      SANDBOX_DEV.dbo.aliveSPs (includes [count] column).
--
--    Parameters:       @truncateExistingData (BIT) = 1
--                            @database_name VARCHAR(100) -- no default, mandatory.
--------------------------------------------------------------------------------

SET NOCOUNT ON
-- get a list of all stored procedures in non-system databases into a temp table var
DECLARE @t1_94875234 TABLE (
      [uid] BIGINT IDENTITY(1,1) PRIMARY KEY NOT NULL,
      [database_name] VARCHAR(100), [schema_name] VARCHAR(MAX),         [proc_name] VARCHAR(MAX),
      [concat_string] AS ( [schema_name] + '.' + [proc_name] ),
      [count] BIGINT DEFAULT 0 NULL
      )
DECLARE @sql NVARCHAR(MAX)
SET @sql=N'
IF ''?'' NOT IN (''MASTER'',''MODEL'',''MSDB'',''TEMPDB'')
BEGIN
      SELECT      ''?'' AS [database_name], SCHEMA_NAME(p.[schema_id]) [schema_name],      p.[name], 0
      FROM  ?.sys.procedures p WITH (NOLOCK)
      WHERE p.type_desc = ''SQL_STORED_PROCEDURE''
END
'
INSERT INTO @t1_94875234
      EXEC sp_msforeachdb @command1 = @sql

-- target for trace data
IF NOT EXISTS(SELECT [name] FROM SANDBOX_DEV.sys.tables WHERE [name] = 'spAuditTraceData') BEGIN
      CREATE TABLE SANDBOX_DEV.dbo.spAuditTraceData (
            database_id INT, text_data VARCHAR(MAX) ) END
ELSE BEGIN
      TRUNCATE TABLE SANDBOX_DEV.dbo.spAuditTraceData END

-- sample data, remove for real.  I included this for testing the SP.
INSERT INTO SANDBOX_DEV.dbo.spAuditTraceData
      SELECT 5, 'EXEC dbo.BuildPrincipalsMap'
      UNION ALL
      SELECT 5, 'EXEC dbo.showJobStats'
      UNION ALL
      SELECT 5, 'EXEC dbo.showJobStats'
      UNION ALL
      SELECT 5, 'EXEC dbo.RebuildIndexes'
      UNION ALL
      SELECT 6, 'EXEC dbo.sp_index_defrag' -- this shouldn't show in results, wrong DBID
      UNION ALL
      SELECT 5, 'EXEC dbo.whatalotofbollocks' -- nor should this, right DB but not a valid SP

-- compare trace data against procedure definition data, add a notch for every match, dependent on given DBID
DECLARE @currentDBID INT, @textdata VARCHAR(MAX), @procdata VARCHAR(MAX), @doesmatch BIT, @puid BIGINT
SET @currentDBID = ( SELECT d.[database_id] FROM sys.databases d WHERE d.[name] = @database_name )  -- put the current DB in here
DECLARE cur_ForEachCall CURSOR LOCAL FAST_FORWARD
FOR ( SELECT text_data FROM SANDBOX_DEV.dbo.spAuditTraceData WHERE database_id = @currentDBID )
OPEN cur_ForEachCall
FETCH NEXT FROM cur_ForEachCall INTO @textdata

WHILE @@FETCH_STATUS = 0
BEGIN
      DECLARE cur_ForEachProc CURSOR LOCAL FAST_FORWARD
      FOR ( SELECT [uid], concat_string FROM @t1_94875234 t INNER JOIN sys.databases d ON t.database_name = d.[name]
            WHERE d.[database_id] = @currentDBID )
      OPEN cur_ForEachProc
      FETCH NEXT FROM cur_ForEachProc INTO @puid, @procdata
      WHILE @@FETCH_STATUS = 0
      BEGIN
            SET @doesmatch = CASE   WHEN CHARINDEX(@procdata,@textdata,1) IS NOT NULL
                                                AND CHARINDEX(@procdata,@textdata,1) <> 0 THEN 1 ELSE 0 END
            IF    @doesmatch = 1
            BEGIN
                  UPDATE @t1_94875234 SET [count] = [count] + 1 WHERE [uid] = @puid
            END
            FETCH NEXT FROM cur_ForEachProc INTO @puid, @procdata
      END
      CLOSE cur_ForEachProc
      DEALLOCATE cur_ForEachProc
      FETCH NEXT FROM cur_ForEachCall INTO @textdata
END
CLOSE cur_ForEachCall
DEALLOCATE cur_ForEachCall

IF NOT EXISTS(SELECT [name] FROM SANDBOX_DEV.sys.tables WHERE [name] = 'deadSPs') BEGIN
      CREATE TABLE SANDBOX_DEV.dbo.deadSPs ( database_name VARCHAR(100), [schema_name] VARCHAR(100), proc_name VARCHAR(MAX) ) END
ELSE BEGIN
      IF @truncateExistingData = 1 BEGIN
            TRUNCATE TABLE SANDBOX_DEV.dbo.deadSPs END END

INSERT INTO SANDBOX_DEV.dbo.deadSPs
      SELECT database_name, [schema_name], [proc_name] FROM @t1_94875234 WHERE [count] = 0 AND database_name = @database_name

IF NOT EXISTS(SELECT [name] FROM SANDBOX_DEV.sys.tables WHERE [name] = 'aliveSPs') BEGIN
      CREATE TABLE SANDBOX_DEV.dbo.aliveSPs ( database_name VARCHAR(100), [schema_name] VARCHAR(100), proc_name VARCHAR(MAX), [count] BIGINT ) END
ELSE BEGIN
      IF @truncateExistingData = 1 BEGIN
            TRUNCATE TABLE SANDBOX_DEV.dbo.aliveSPs END END

INSERT INTO SANDBOX_DEV.dbo.aliveSPs
      SELECT database_name, [schema_name], [proc_name], [count] FROM @t1_94875234 WHERE [count] <> 0 AND database_name = @database_name

PRINT 'Comparison has finished.  Check dbo.deadSPs and dbo.aliveSPs for results.'

END




Step 8:  Using my sample data (6 ‘calls’ in my trace table) I get results like the following:

‘Comparison has finished.  Check dbo.deadSPs and dbo.aliveSPs for results.’

When we query the tables dbo.deadSPs and dbo.aliveSPs, we get (YMMV):

dbo.deadSPs


Dbo.aliveSPs








Now, you can see how easy it is to generate a list of stored procedures that have been in use in the last X minutes, days or hours.  You can use the dbo.deadSPs and dbo.aliveSPs with your own filters to generate your lists.

If you have found this useful / found problems with the scripts / want further clarification / fume at my shameless use of RBAR (delete as appropriate), please feel free to leave a comment below.

Until next time…

Del