It’s time to get down to business people; in Part 1 we pimped the Azure databases to return health data XML, in Part 2 we made a control database that knew where these databases were at, and in Part 3 we hoovered out the XML data we wanted from them via PowerShell. Sweet. But data aint no good sitting there doing nothing, we gotta make it sing.
So let’s add some tables to our local health database.
-- 01 Table to hold result sets (one per run of the PowerShell Script)
CREATE TABLE [dbhealth].[azureResultSet](
[azResultSetID] [int] IDENTITY(1,1) NOT NULL,
[azResultSetDate] [datetime] NOT NULL,
CONSTRAINT [PK_sazResultSet] PRIMARY KEY CLUSTERED ([azResultSetID] ASC)
)
GO
-- 02 Table to hold a list of the Azure servers that data is being returned from
CREATE TABLE [dbhealth].[azureServer](
[azServerID] [smallint] IDENTITY(1,1) NOT NULL,
[azServerName] [varchar](100) NOT NULL,
CONSTRAINT [PK_sazServer] PRIMARY KEY CLUSTERED ([azServerID] ASC)
)
GO
-- 03 Table to hold a list of the databases that data is being returned from
CREATE TABLE [dbhealth].[azureDatabase](
[azDatabaseID] [smallint] IDENTITY(1,1) NOT NULL,
[azServerID] [smallint] NOT NULL,
[azDatabaseName] [nvarchar](100) NOT NULL,
CONSTRAINT [PK_sazDatabase] PRIMARY KEY CLUSTERED ([azDatabaseID] ASC)
)
GO
ALTER TABLE [dbhealth].[azureDatabase] WITH CHECK ADD CONSTRAINT [FK_azureDatabase_azureServer] FOREIGN KEY([azServerID])
REFERENCES [dbhealth].[azureServer] ([azServerID])
GO
ALTER TABLE [dbhealth].[azureDatabase] CHECK CONSTRAINT [FK_azureDatabase_azureServer]
GO
-- 04 Table to hold a list of the databases in the given result set that had an error returning data
CREATE TABLE [dbhealth].[azureDBError](
[azResultSetID] [int] NOT NULL,
[azDatabaseID] [smallint] NOT NULL,
[azDBError] [nvarchar](max) NOT NULL,
CONSTRAINT [PK_azureDBError] PRIMARY KEY CLUSTERED ([azResultSetID] ASC, [azDatabaseID] ASC)
)
GO
ALTER TABLE [dbhealth].[azureDBError] WITH CHECK ADD CONSTRAINT [FK_azureDBError_azureDatabase] FOREIGN KEY([azDatabaseID])
REFERENCES [dbhealth].[azureDatabase] ([azDatabaseID])
GO
ALTER TABLE [dbhealth].[azureDBError] CHECK CONSTRAINT [FK_azureDBError_azureDatabase]
GO
ALTER TABLE [dbhealth].[azureDBError] WITH CHECK ADD CONSTRAINT [FK_azureDBError_azureResultSet1] FOREIGN KEY([azResultSetID])
REFERENCES [dbhealth].[azureResultSet] ([azResultSetID])
GO
ALTER TABLE [dbhealth].[azureDBError] CHECK CONSTRAINT [FK_azureDBError_azureResultSet1]
GO
ALTER TABLE [dbhealth].[azureDBError] ADD CONSTRAINT [DF_azureDBError_azDBError] DEFAULT ('') FOR [azDBError]
GO
-- 05 Table to hold the sizes of the databases in the given result set
CREATE TABLE [dbhealth].[azureDBSize](
[azResultSetID] [int] NOT NULL,
[azDatabaseID] [smallint] NOT NULL,
[dbSize] [decimal](18, 9) NOT NULL,
[dbMaxSize] [decimal](18, 9) NOT NULL,
[dbPctUsed] [decimal](8, 6) NOT NULL,
CONSTRAINT [PK_azureDBSize_1] PRIMARY KEY CLUSTERED ([azResultSetID] ASC,[azDatabaseID] ASC)
)
GO
ALTER TABLE [dbhealth].[azureDBSize] WITH CHECK ADD CONSTRAINT [FK_azureDBSize_azureDatabase] FOREIGN KEY([azDatabaseID])
REFERENCES [dbhealth].[azureDatabase] ([azDatabaseID])
GO
ALTER TABLE [dbhealth].[azureDBSize] CHECK CONSTRAINT [FK_azureDBSize_azureDatabase]
GO
ALTER TABLE [dbhealth].[azureDBSize] WITH CHECK ADD CONSTRAINT [FK_azureDBSize_azureResultSet] FOREIGN KEY([azResultSetID])
REFERENCES [dbhealth].[azureResultSet] ([azResultSetID])
GO
ALTER TABLE [dbhealth].[azureDBSize] CHECK CONSTRAINT [FK_azureDBSize_azureResultSet]
GO
-- 06 Table to hold the number of connections for the databases in the given result set
CREATE TABLE [dbhealth].[azureDBConnections](
[azResultSetID] [int] NOT NULL,
[azDatabaseID] [smallint] NOT NULL,
[currentConnections] [bigint] NOT NULL,
[lastRequestTime] [datetime] NOT NULL,
CONSTRAINT [PK_azureDBConnections_1] PRIMARY KEY CLUSTERED ([azResultSetID] ASC,[azDatabaseID] ASC)
)
GO
ALTER TABLE [dbhealth].[azureDBConnections] WITH CHECK ADD CONSTRAINT [FK_azureDBConnections_azureDatabase] FOREIGN KEY([azDatabaseID])
REFERENCES [dbhealth].[azureDatabase] ([azDatabaseID])
GO
ALTER TABLE [dbhealth].[azureDBConnections] CHECK CONSTRAINT [FK_azureDBConnections_azureDatabase]
GO
ALTER TABLE [dbhealth].[azureDBConnections] WITH CHECK ADD CONSTRAINT [FK_azureDBConnections_azureResultSet] FOREIGN KEY([azResultSetID])
REFERENCES [dbhealth].[azureResultSet] ([azResultSetID])
GO
ALTER TABLE [dbhealth].[azureDBConnections] CHECK CONSTRAINT [FK_azureDBConnections_azureResultSet]
GO
-- 07 Table to hold the object sizes for the databases in the given result set
CREATE TABLE [dbhealth].[azureDBObjectSize](
[azResultSetID] [int] NOT NULL,
[azDatabaseID] [smallint] NOT NULL,
[rowRank] [smallint] NOT NULL,
[objectType] [varchar](80) NOT NULL,
[objectName] [nvarchar](100) NOT NULL,
[countRows] [bigint] NOT NULL,
[sizeMB] [decimal](18, 9) NOT NULL,
CONSTRAINT [PK_azureDBTableCounts] PRIMARY KEY CLUSTERED
(
[azResultSetID] ASC,
[azDatabaseID] ASC,
[rowRank] ASC
)
)
GO
ALTER TABLE [dbhealth].[azureDBObjectSize] WITH CHECK ADD CONSTRAINT [FK_azureDBObjectSize_azureDatabase] FOREIGN KEY([azDatabaseID])
REFERENCES [dbhealth].[azureDatabase] ([azDatabaseID])
GO
ALTER TABLE [dbhealth].[azureDBObjectSize] CHECK CONSTRAINT [FK_azureDBObjectSize_azureDatabase]
GO
ALTER TABLE [dbhealth].[azureDBObjectSize] WITH CHECK ADD CONSTRAINT [FK_azureDBObjectSize_azureResultSet] FOREIGN KEY([azResultSetID])
REFERENCES [dbhealth].[azureResultSet] ([azResultSetID])
GO
ALTER TABLE [dbhealth].[azureDBObjectSize] CHECK CONSTRAINT [FK_azureDBObjectSize_azureResultSet]
GO
-- 08 Table to hold the exec CPU figures for the databases in the given result set
CREATE TABLE [dbhealth].[azureDBExecCPU](
[azResultSetID] [int] NOT NULL,
[azDatabaseID] [smallint] NOT NULL,
[rowRank] [smallint] NOT NULL,
[avgCPUTime] [decimal](18, 9) NOT NULL,
[avgReads] [bigint] NOT NULL,
[avgWrites] [bigint] NOT NULL,
[owningObjName] [nvarchar](128) NOT NULL,
[sqlText] [nvarchar](max) NOT NULL,
CONSTRAINT [PK_azureDBExecCPU] PRIMARY KEY CLUSTERED
(
[azResultSetID] ASC,
[azDatabaseID] ASC,
[rowRank] ASC
)
)
GO
ALTER TABLE [dbhealth].[azureDBExecCPU] WITH CHECK ADD CONSTRAINT [FK_azureDBExecCPU_azureDatabase] FOREIGN KEY([azDatabaseID])
REFERENCES [dbhealth].[azureDatabase] ([azDatabaseID])
GO
ALTER TABLE [dbhealth].[azureDBExecCPU] CHECK CONSTRAINT [FK_azureDBExecCPU_azureDatabase]
GO
ALTER TABLE [dbhealth].[azureDBExecCPU] WITH CHECK ADD CONSTRAINT [FK_azureDBExecCPU_azureResultSet] FOREIGN KEY([azResultSetID])
REFERENCES [dbhealth].[azureResultSet] ([azResultSetID])
GO
-- 09 Table to hold the plan CPU figures for the databases in the given result set
CREATE TABLE [dbhealth].[azureDBPlanCPU](
[azResultSetID] [int] NOT NULL,
[azDatabaseID] [smallint] NOT NULL,
[rowRank] [smallint] NOT NULL,
[totalWorkerTime] [bigint] NOT NULL,
[executionCount] [bigint] NOT NULL,
[encrypted] [bit] NOT NULL,
[sqlText] [nvarchar](max) NOT NULL,
CONSTRAINT [PK_azureDBPlanCPU] PRIMARY KEY CLUSTERED
(
[azResultSetID] ASC,
[azDatabaseID] ASC,
[rowRank] ASC
)
)
GO
ALTER TABLE [dbhealth].[azureDBPlanCPU] WITH CHECK ADD CONSTRAINT [FK_azureDBPlanCPU_azureDatabase] FOREIGN KEY([azDatabaseID])
REFERENCES [dbhealth].[azureDatabase] ([azDatabaseID])
GO
ALTER TABLE [dbhealth].[azureDBPlanCPU] CHECK CONSTRAINT [FK_azureDBPlanCPU_azureDatabase]
GO
ALTER TABLE [dbhealth].[azureDBPlanCPU] WITH CHECK ADD CONSTRAINT [FK_azureDBPlanCPU_azureResultSet] FOREIGN KEY([azResultSetID])
REFERENCES [dbhealth].[azureResultSet] ([azResultSetID])
GO
ALTER TABLE [dbhealth].[azureDBPlanCPU] CHECK CONSTRAINT [FK_azureDBPlanCPU_azureResultSet]
GO
Tidy. So what exactly we got now?
- A table (azureResultSet) to hold result sets, one per run. If you check the PowerShell script in Part 3 you’ll dig that we instantiate a “batch Date” variable prior to the loop and all database data returned is stamped with the same date, so we know which are in the same batch
- A table (azureServer) to hold a list of the Azure servers that data is being returned from
- A table (azureDatabase) to hold a list of the databases that data is being returned from
- A table (azureDBError) to hold a list of the databases in the given result set that had an error returning data
- A table (azureDBSize) to hold the sizes of the databases in the given result set
- A table (azureDBConnections) to hold the number of connections for the databases in the given result set
- A table (azureDBObjectSize) to hold the object sizes for the databases in the given result set
- A table (azureDBExecCPU) to hold the exec CPU figures for the databases in the given result set
- A table (azureDBPlanCPU) to hold the plan CPU figures for the databases in the given result set
All tables bar the result set (azureResultSet), server (azureServer), and database (azureDatabase), have FKs to both the database and the result set. There is also an FK between the server and the database tables. Tight.
Dig also that we’ve not granted any permissions to the dbHealthCheck user and that’s because we’re only going to grant them EXECUTE permission to the stored procedure that does the shredding. They don’t need direct table access so they’re not getting it. Least privilege all the way with me baby.
So let’s get shredding. I rock one stored procedure that shreds the data for all unprocessed results (records in the incomingXML table).
CREATE PROC dbhealth.processDBDashboardStats
AS
DECLARE @azServerName VARCHAR(100)
DECLARE @azDatabaseName VARCHAR(100)
DECLARE @azServerID SMALLINT
DECLARE @azDatabaseID SMALLINT
DECLARE @azResultSetID INT
SET NOCOUNT ON;
BEGIN TRY
-- see if we have a batch or batches to process and if not just exit
IF (SELECT COUNT(1) FROM dbhealth.incomingXML) = 0 RETURN
BEGIN TRAN
-- add any new servers we haven't processed before
-- (I don't care for DISTINCT but needed here as xml methods are
-- not allowed in GROUP BY clauses)
-- done as a MERGE as I may want to include update logic down the line
MERGE dbhealth.azureServer AS TARGET
USING
(
SELECT DISTINCT N.C.value('@serverName', 'nvarchar(100)')
FROM dbhealth.incomingXML
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
) AS SOURCE (azServerName)
ON (TARGET.azServerName = SOURCE.azServerName)
WHEN NOT MATCHED THEN
INSERT (azServerName)
VALUES (source.azServerName);
-- add any new databases we haven't processed before
-- again, done as a MERGE as I may want to include update logic down the line
MERGE dbhealth.azureDatabase AS TARGET
USING
(
SELECT N.C.value('@databaseName', 'nvarchar(100)') as azDatabaseName,
azServ.azServerID
FROM dbhealth.incomingXML iXml
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
INNER JOIN dbhealth.azureServer azServ
ON N.C.value('@serverName', 'nvarchar(100)') = azServ.azServerName
) AS SOURCE (azDatabaseName, azServerID)
ON (TARGET.azDatabaseName = SOURCE.azDatabaseName
AND TARGET.azServerID = SOURCE.azServerID)
WHEN NOT MATCHED THEN
INSERT (azServerID, azDatabaseName)
VALUES (SOURCE.azServerID, SOURCE.azDatabaseName);
-- add the result set batches
INSERT INTO dbhealth.azureResultSet(azResultSetDate)
SELECT azResultSetDate
FROM dbhealth.incomingXML
GROUP BY azResultSetDate
-- add any error records (db failed to return data)
INSERT INTO dbhealth.azureDBError(azResultSetID, azDatabaseID, azDBError)
SELECT azRSet.azResultSetID,
azDB.azDatabaseID,
N.C.value('errordesc[1]', 'nvarchar(max)') AS sqlText
FROM dbhealth.incomingXML iXml
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
INNER JOIN dbhealth.azureServer azServ
ON N.C.value('@serverName', 'nvarchar(100)') = azServ.azServerName
INNER JOIN dbhealth.azureDatabase azDB
ON N.C.value('@databaseName', 'nvarchar(100)') = azDB.azDatabaseName
AND azServ.azServerID = azDB.azServerID
INNER JOIN dbhealth.azureResultSet azRSet
on iXML.azResultSetDate = azRSet.azResultSetDate
WHERE iXml.ixXMLType = 'DB CHECK: ERROR'
-- add the db size records
INSERT dbhealth.azureDBSize(azResultSetID, azDatabaseID, dbSize, dbMaxSize, dbPctUsed)
SELECT azRSet.azResultSetID,
azDB.azDatabaseID,
N.C.value('dbcheck_size[1]/@dbSizeMB', 'decimal(18,9)') AS dbSize,
N.C.value('dbcheck_size[1]/@dbSizeMB_Max', 'decimal(18,9)') AS dbMaxSize,
N.C.value('dbcheck_size[1]/@dbSize_PctUsed', 'decimal(8,6)') AS dbPctUsed
FROM dbhealth.incomingXML iXml
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
INNER JOIN dbhealth.azureServer azServ
ON N.C.value('@serverName', 'nvarchar(100)') = azServ.azServerName
INNER JOIN dbhealth.azureDatabase azDB
ON N.C.value('@databaseName', 'nvarchar(100)') = azDB.azDatabaseName
AND azServ.azServerID = azDB.azServerID
INNER JOIN dbhealth.azureResultSet azRSet
on iXML.azResultSetDate = azRSet.azResultSetDate
WHERE iXml.ixXMLType = 'DB CHECK'
-- get the connections
INSERT dbhealth.azureDBConnections(azResultSetID, azDatabaseID, currentConnections, lastRequestTime)
SELECT azRSet.azResultSetID,
azDB.azDatabaseID,
N.C.value('dbcheck_connections[1]/@connections', 'bigint') AS currentConnections,
N.C.value('dbcheck_connections[1]/@lastrequestendtime', 'datetime') AS lastRequestTime
FROM dbhealth.incomingXML iXml
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
INNER JOIN dbhealth.azureServer azServ
ON N.C.value('@serverName', 'nvarchar(100)') = azServ.azServerName
INNER JOIN dbhealth.azureDatabase azDB
ON N.C.value('@databaseName', 'nvarchar(100)') = azDB.azDatabaseName
AND azServ.azServerID = azDB.azServerID
INNER JOIN dbhealth.azureResultSet azRSet
on iXML.azResultSetDate = azRSet.azResultSetDate
WHERE iXml.ixXMLType = 'DB CHECK'
-- get the object size data
INSERT dbhealth.azureDBObjectSize(azResultSetID, azDatabaseID, rowRank, objectType, objectName, countRows, sizeMB)
SELECT azRSet.azResultSetID,
azDB.azDatabaseID,
N2.C2.value('@rowRank[1]', 'smallint') AS rowRank,
N2.C2.value('@objectType[1]', 'varchar(80)') AS objectType,
N2.C2.value('@objectName[1]', 'nvarchar(100)') AS objectName,
N2.C2.value('@countRows[1]', 'bigint') AS countRows,
N2.C2.value('@sizeMB[1]', 'decimal(18,9)') AS sizeMB
FROM dbhealth.incomingXML iXml
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
CROSS APPLY N.C.nodes('//dbcheck_objsizes//dbcheck_objsize') N2(C2)
INNER JOIN dbhealth.azureServer azServ
ON N.C.value('@serverName[1]', 'nvarchar(100)') = azServ.azServerName
INNER JOIN dbhealth.azureDatabase azDB
ON N.C.value('@databaseName[1]', 'nvarchar(100)') = azDB.azDatabaseName
AND azServ.azServerID = azDB.azServerID
INNER JOIN dbhealth.azureResultSet azRSet
on iXML.azResultSetDate = azRSet.azResultSetDate
WHERE iXml.ixXMLType = 'DB CHECK'
-- get the exec data (CPU)
INSERT dbhealth.azureDBExecCPU(azResultSetID, azDatabaseID, rowRank, avgCPUTime, avgReads, avgWrites, owningObjName, sqlText)
SELECT azRSet.azResultSetID,
azDB.azDatabaseID,
N2.C2.value('@rowRank[1]', 'smallint') AS rowRank,
N2.C2.value('@avgCPUTime[1]', 'decimal(18,9)') AS avgCPUTime,
N2.C2.value('@avgReads[1]', 'bigint') AS avgReads,
N2.C2.value('@avgWrites[1]', 'bigint') AS avgWrites,
N2.C2.value('@owningObjName[1]', 'nvarchar(128)') AS owningObjName,
N2.C2.value('sqlText[1]', 'nvarchar(max)') AS sqlText
FROM dbhealth.incomingXML iXml
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
CROSS APPLY N.C.nodes('//dbcheck_execs_cpu//dbcheck_exec_cpu') N2(C2)
INNER JOIN dbhealth.azureServer azServ
ON N.C.value('@serverName[1]', 'nvarchar(100)') = azServ.azServerName
INNER JOIN dbhealth.azureDatabase azDB
ON N.C.value('@databaseName[1]', 'nvarchar(100)') = azDB.azDatabaseName
AND azServ.azServerID = azDB.azServerID
INNER JOIN dbhealth.azureResultSet azRSet
on iXML.azResultSetDate = azRSet.azResultSetDate
WHERE iXml.ixXMLType = 'DB CHECK'
-- get the query plan data
INSERT dbhealth.azureDBPlanCPU(azResultSetID, azDatabaseID, rowRank, totalWorkerTime, executionCount, encrypted, sqlText)
SELECT azRSet.azResultSetID,
azDB.azDatabaseID,
N2.C2.value('@rowRank[1]', 'smallint') AS rowRank,
N2.C2.value('@totalWorkerTime[1]', 'bigint') AS totalWorkerTime,
N2.C2.value('@executionCount', 'bigint') AS executionCount,
N2.C2.value('@encrypted[1]', 'bit') AS encrypted,
N2.C2.value('sqlText[1]', 'nvarchar(max)') AS sqlText
FROM dbhealth.incomingXML iXml
CROSS APPLY ixXML.nodes('//dbchecks') N(C)
CROSS APPLY N.C.nodes('//dbcheck_plans_cpu//dbcheck_plan_cpu') N2(C2)
INNER JOIN dbhealth.azureServer azServ
ON N.C.value('@serverName[1]', 'nvarchar(100)') = azServ.azServerName
INNER JOIN dbhealth.azureDatabase azDB
ON N.C.value('@databaseName[1]', 'nvarchar(100)') = azDB.azDatabaseName
AND azServ.azServerID = azDB.azServerID
INNER JOIN dbhealth.azureResultSet azRSet
on iXML.azResultSetDate = azRSet.azResultSetDate
WHERE iXml.ixXMLType = 'DB CHECK'
-- now delete the incoming xml records that we have processed
delete imxl
from dbhealth.incomingXML imxl
inner join dbhealth.azureResultSet azr
on imxl.azResultSetDate = azr.azResultSetDate
COMMIT TRAN
END TRY
BEGIN CATCH
DECLARE @ErrorMessage NVARCHAR(4000)
DECLARE @ErrorSeverity INT
DECLARE @ErrorState INT
SELECT @ErrorMessage = ERROR_MESSAGE(),
@ErrorSeverity = ERROR_SEVERITY(),
@ErrorState = ERROR_STATE();
ROLLBACK TRAN;
RAISERROR (@ErrorMessage, @ErrorSeverity, @ErrorState);
END CATCH
GO
GRANT EXECUTE ON dbhealth.processDBDashboardStats to HealthCheck;
GO
Run this and you’ll be laughing. Smooth. I run this procedure straight after the PowerShell script, so I have fresh data coming in every six hours via my agent job: PowerShell script Job Step 1, stored procedure Job Step 2. Sorted. The schedule of your collection will depend on the volatility of your databases and you may want to start at an hour and then lengthen from there depending on what you’re seeing coming back.
In the next instalments I’ll check out ways to analyse the data, starting with Part 5 showing how to generate critical alert emails. So till then you best get hoovering and shredding so you’ve got something to work with.
Word.