diff --git a/.github/sql/ci_validate_installation.sql b/.github/sql/ci_validate_installation.sql
new file mode 100644
index 00000000..77187200
--- /dev/null
+++ b/.github/sql/ci_validate_installation.sql
@@ -0,0 +1,207 @@
+/*
+CI Validation: Verify all expected objects exist after installation.
+Run with sqlcmd -b to fail on RAISERROR.
+*/
+
+SET NOCOUNT ON;
+
+USE PerformanceMonitor;
+GO
+
+PRINT '========================================';
+PRINT 'CI Installation Validation';
+PRINT '========================================';
+PRINT '';
+
+DECLARE
+ @missing int = 0,
+ @checked int = 0;
+
+/*
+Schemas (4)
+*/
+PRINT 'Checking schemas...';
+
+IF SCHEMA_ID(N'collect') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema collect'; END; SET @checked += 1;
+IF SCHEMA_ID(N'analyze') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema analyze'; END; SET @checked += 1;
+IF SCHEMA_ID(N'config') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema config'; END; SET @checked += 1;
+IF SCHEMA_ID(N'report') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: schema report'; END; SET @checked += 1;
+
+PRINT '';
+
+/*
+Procedures in collect schema (37)
+*/
+PRINT 'Checking collect procedures...';
+
+IF OBJECT_ID(N'collect.calculate_deltas', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.calculate_deltas'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.wait_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.wait_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.query_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.query_store_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_store_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.procedure_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.procedure_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.query_snapshots_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.query_snapshots_create_views', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_create_views'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.query_snapshots_retention', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.query_snapshots_retention'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.memory_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.memory_grant_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_grant_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.memory_clerks_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_clerks_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.cpu_scheduler_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.cpu_scheduler_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.cpu_utilization_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.cpu_utilization_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.perfmon_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.perfmon_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.file_io_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.file_io_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.blocked_process_xml_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.blocked_process_xml_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.process_blocked_process_xml', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.process_blocked_process_xml'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.deadlock_xml_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.deadlock_xml_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.process_deadlock_xml', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.process_deadlock_xml'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.blocking_deadlock_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.blocking_deadlock_analyzer'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.memory_pressure_events_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.memory_pressure_events_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.system_health_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.system_health_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.default_trace_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.default_trace_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.trace_management_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.trace_management_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.trace_analysis_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.trace_analysis_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.latch_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.latch_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.spinlock_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.spinlock_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.tempdb_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.tempdb_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.plan_cache_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.plan_cache_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.session_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.session_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.waiting_tasks_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.waiting_tasks_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.session_wait_stats_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.session_wait_stats_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.server_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.server_configuration_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.database_configuration_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.database_configuration_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.configuration_issues_analyzer', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.configuration_issues_analyzer'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.scheduled_master_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.scheduled_master_collector'; END; SET @checked += 1;
+IF OBJECT_ID(N'collect.running_jobs_collector', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: collect.running_jobs_collector'; END; SET @checked += 1;
+
+PRINT '';
+
+/*
+Procedures in config schema (10)
+*/
+PRINT 'Checking config procedures...';
+
+IF OBJECT_ID(N'config.ensure_config_tables', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.ensure_config_tables'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.ensure_collection_table', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.ensure_collection_table'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.update_collector_frequency', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.update_collector_frequency'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.set_collector_enabled', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.set_collector_enabled'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.enable_realtime_monitoring', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.enable_realtime_monitoring'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.enable_consulting_analysis', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.enable_consulting_analysis'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.enable_baseline_monitoring', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.enable_baseline_monitoring'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.show_collection_schedule', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.show_collection_schedule'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.data_retention', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.data_retention'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.check_hung_collector_job', N'P') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.check_hung_collector_job'; END; SET @checked += 1;
+
+PRINT '';
+
+/*
+Views in config schema (2)
+*/
+PRINT 'Checking config views...';
+
+IF OBJECT_ID(N'config.current_version', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.current_version'; END; SET @checked += 1;
+IF OBJECT_ID(N'config.server_info', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: config.server_info'; END; SET @checked += 1;
+
+PRINT '';
+
+/*
+Views in report schema (37)
+Note: report.query_snapshots and report.query_snapshots_blocking are created
+dynamically by collect.query_snapshots_create_views, so they are not checked here.
+*/
+PRINT 'Checking report views...';
+
+IF OBJECT_ID(N'report.query_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_stats_with_formatted_plans'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.procedure_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.procedure_stats_with_formatted_plans'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.query_store_stats_with_formatted_plans', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_stats_with_formatted_plans'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.expensive_queries_today', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.expensive_queries_today'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.query_stats_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_stats_summary'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.procedure_stats_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.procedure_stats_summary'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.query_store_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_summary'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.collection_health', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.collection_health'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.top_waits_last_hour', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_waits_last_hour'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.memory_pressure_events', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_pressure_events'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.cpu_spikes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.cpu_spikes'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.blocking_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.blocking_summary'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.deadlock_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.deadlock_summary'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.daily_summary', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.daily_summary'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.daily_summary_v2', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.daily_summary_v2'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.server_configuration_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.server_configuration_changes'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.database_configuration_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.database_configuration_changes'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.trace_flag_changes', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.trace_flag_changes'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.top_latch_contention', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_latch_contention'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.top_spinlock_contention', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_spinlock_contention'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.tempdb_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.tempdb_pressure'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.plan_cache_bloat', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.plan_cache_bloat'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.top_memory_consumers', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.top_memory_consumers'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.memory_grant_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_grant_pressure'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.file_io_latency', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.file_io_latency'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.cpu_scheduler_pressure', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.cpu_scheduler_pressure'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.long_running_query_patterns', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.long_running_query_patterns'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.memory_pressure_indicators', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_pressure_indicators'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.file_io_wait_correlation', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.file_io_wait_correlation'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.blocking_chain_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.blocking_chain_analysis'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.tempdb_contention_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.tempdb_contention_analysis'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.parameter_sensitivity_detection', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.parameter_sensitivity_detection'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.scheduler_cpu_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.scheduler_cpu_analysis'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.session_wait_analysis', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.session_wait_analysis'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.critical_issues', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.critical_issues'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.memory_usage_trends', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.memory_usage_trends'; END; SET @checked += 1;
+IF OBJECT_ID(N'report.running_jobs', N'V') IS NULL BEGIN SET @missing += 1; PRINT ' MISSING: report.running_jobs'; END; SET @checked += 1;
+
+PRINT '';
+
+/*
+Functions in report schema (1)
+*/
+PRINT 'Checking report functions...';
+
+IF OBJECT_ID(N'report.query_store_regressions', N'IF') IS NULL
+AND OBJECT_ID(N'report.query_store_regressions', N'TF') IS NULL
+AND OBJECT_ID(N'report.query_store_regressions', N'FN') IS NULL
+BEGIN SET @missing += 1; PRINT ' MISSING: report.query_store_regressions'; END;
+SET @checked += 1;
+
+PRINT '';
+
+/*
+Table count checks (minimum expected per schema)
+*/
+PRINT 'Checking table counts...';
+
+DECLARE
+ @collect_tables int,
+ @config_tables int;
+
+SELECT @collect_tables = COUNT_BIG(*)
+FROM sys.tables AS t
+WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'collect';
+
+SELECT @config_tables = COUNT_BIG(*)
+FROM sys.tables AS t
+WHERE OBJECT_SCHEMA_NAME(t.object_id) = N'config';
+
+PRINT ' collect schema tables: ' + CONVERT(varchar(10), @collect_tables);
+PRINT ' config schema tables: ' + CONVERT(varchar(10), @config_tables);
+
+IF @collect_tables < 20 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 20 collect tables, found ' + CONVERT(varchar(10), @collect_tables); END; SET @checked += 1;
+IF @config_tables < 5 BEGIN SET @missing += 1; PRINT ' MISSING: expected >= 5 config tables, found ' + CONVERT(varchar(10), @config_tables); END; SET @checked += 1;
+
+PRINT '';
+
+/*
+Summary
+*/
+PRINT '========================================';
+PRINT 'Checked ' + CONVERT(varchar(10), @checked) + ' objects';
+
+IF @missing > 0
+BEGIN
+ PRINT 'FAILED: ' + CONVERT(varchar(10), @missing) + ' object(s) missing';
+ RAISERROR('CI validation failed: %d object(s) missing', 16, 1, @missing);
+END;
+ELSE
+BEGIN
+ PRINT 'PASSED: All objects present';
+END;
+
+PRINT '========================================';
+GO
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 593b9e05..1b83eaba 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -4,7 +4,7 @@ on:
push:
branches: [main]
pull_request:
- branches: [main]
+ branches: [main, dev]
release:
types: [created]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..a92afdd1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,38 @@
+name: CI
+
+on:
+ push:
+ branches: [dev]
+ pull_request:
+ branches: [dev]
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 8.0
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Restore dependencies
+ run: |
+ dotnet restore Dashboard/Dashboard.csproj
+ dotnet restore Lite/PerformanceMonitorLite.csproj
+ dotnet restore Installer/PerformanceMonitorInstaller.csproj
+ dotnet restore InstallerGui/InstallerGui.csproj
+ dotnet restore Lite.Tests/Lite.Tests.csproj
+
+ - name: Build all projects
+ run: |
+ dotnet build Dashboard/Dashboard.csproj -c Release --no-restore
+ dotnet build Lite/PerformanceMonitorLite.csproj -c Release --no-restore
+ dotnet build Installer/PerformanceMonitorInstaller.csproj -c Release --no-restore
+ dotnet build InstallerGui/InstallerGui.csproj -c Release --no-restore
+ dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore
+
+ - name: Run tests
+ run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --no-build --verbosity normal
diff --git a/.github/workflows/sql-validation.yml b/.github/workflows/sql-validation.yml
new file mode 100644
index 00000000..fe06470f
--- /dev/null
+++ b/.github/workflows/sql-validation.yml
@@ -0,0 +1,79 @@
+name: SQL Validation
+
+on:
+ push:
+ branches: [dev]
+ paths: ['install/**']
+ pull_request:
+ branches: [dev]
+ paths: ['install/**']
+
+jobs:
+ validate-sql:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - version: '2017'
+ image: mcr.microsoft.com/mssql/server:2017-latest
+ - version: '2019'
+ image: mcr.microsoft.com/mssql/server:2019-latest
+ - version: '2022'
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ - version: '2025'
+ image: mcr.microsoft.com/mssql/server:2025-latest
+
+ name: SQL Server ${{ matrix.version }}
+
+ services:
+ sqlserver:
+ image: ${{ matrix.image }}
+ env:
+ ACCEPT_EULA: Y
+ MSSQL_SA_PASSWORD: CI_Test#2026!
+ ports:
+ - 1433:1433
+ options: >-
+ --health-cmd "grep -q 'SQL Server is now ready for client connections' /var/opt/mssql/log/errorlog || exit 1"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 15
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install sqlcmd
+ run: |
+ # Ubuntu 24.04 runners have Microsoft repo pre-configured; avoid Signed-By conflicts
+ if ! grep -rql 'packages.microsoft.com' /etc/apt/sources.list.d/ 2>/dev/null; then
+ curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
+ source /etc/os-release
+ echo "deb [arch=amd64,signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${VERSION_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list
+ fi
+ sudo apt-get update
+ sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18
+
+ - name: Run install scripts
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ run: |
+ for script in $(ls install/[0-9]*.sql | sort); do
+ filename=$(basename "$script")
+
+ # Skip scripts that require SQL Agent or are test/troubleshooting
+ case "$filename" in
+ 45_*) echo "Skipping $filename (requires SQL Agent)"; continue;;
+ 97_*|98_*|99_*) echo "Skipping $filename (test/troubleshooting)"; continue;;
+ esac
+
+ echo "Running $filename..."
+ /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "$script"
+ echo " OK"
+ done
+
+ - name: Validate installation
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ run: |
+ /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_validate_installation.sql
diff --git a/Dashboard/AboutWindow.xaml b/Dashboard/AboutWindow.xaml
index f7c250ed..5fa28bcb 100644
--- a/Dashboard/AboutWindow.xaml
+++ b/Dashboard/AboutWindow.xaml
@@ -18,7 +18,7 @@
-
+
diff --git a/Dashboard/CollectorScheduleWindow.xaml b/Dashboard/CollectorScheduleWindow.xaml
index e002f72d..ee370e21 100644
--- a/Dashboard/CollectorScheduleWindow.xaml
+++ b/Dashboard/CollectorScheduleWindow.xaml
@@ -40,8 +40,8 @@
-
-
+
+
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml b/Dashboard/Controls/AlertsHistoryContent.xaml
new file mode 100644
index 00000000..41793f4c
--- /dev/null
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
new file mode 100644
index 00000000..9741b2aa
--- /dev/null
+++ b/Dashboard/Controls/AlertsHistoryContent.xaml.cs
@@ -0,0 +1,446 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using Microsoft.Win32;
+using PerformanceMonitorDashboard.Helpers;
+using PerformanceMonitorDashboard.Models;
+using PerformanceMonitorDashboard.Services;
+
+namespace PerformanceMonitorDashboard.Controls
+{
+ public partial class AlertsHistoryContent : UserControl
+ {
+ private List _allAlerts = new();
+
+ /* Column filter state */
+ private readonly Dictionary _columnFilters = new();
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
+
+ public AlertsHistoryContent()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Refreshes the alert history from the in-memory log.
+ ///
+ public void RefreshAlerts()
+ {
+ LoadAlerts();
+ }
+
+ private void LoadAlerts()
+ {
+ var service = EmailAlertService.Current;
+ if (service == null)
+ {
+ AlertsDataGrid.ItemsSource = null;
+ NoAlertsMessage.Visibility = Visibility.Visible;
+ AlertCountIndicator.Text = "";
+ return;
+ }
+
+ var hoursBack = GetSelectedHoursBack();
+ var entries = service.GetAlertHistory(hoursBack > 0 ? hoursBack : 8760, 500);
+
+ _allAlerts = entries.Select(e => new AlertHistoryDisplayItem
+ {
+ AlertTime = e.AlertTime,
+ ServerName = e.ServerName,
+ MetricName = e.MetricName,
+ CurrentValue = e.CurrentValue,
+ ThresholdValue = e.ThresholdValue,
+ NotificationType = e.NotificationType,
+ StatusDisplay = GetStatusDisplay(e),
+ IsResolved = e.MetricName.Contains("Cleared") || e.MetricName.Contains("Resolved"),
+ IsCritical = e.MetricName.Contains("Deadlock") || e.MetricName.Contains("Poison"),
+ IsWarning = !e.MetricName.Contains("Cleared") && !e.MetricName.Contains("Resolved")
+ && !e.MetricName.Contains("Deadlock") && !e.MetricName.Contains("Poison")
+ }).ToList();
+
+ ApplyFilters();
+ }
+
+ private void ApplyFilters()
+ {
+ var filtered = _allAlerts.AsEnumerable();
+
+ /* Server filter */
+ if (ServerFilterComboBox.SelectedIndex > 0 &&
+ ServerFilterComboBox.SelectedItem is ComboBoxItem serverItem)
+ {
+ var serverName = serverItem.Content?.ToString();
+ if (!string.IsNullOrEmpty(serverName))
+ filtered = filtered.Where(a => a.ServerName == serverName);
+ }
+
+ /* Column filters */
+ if (_columnFilters.Count > 0)
+ {
+ filtered = filtered.Where(item =>
+ {
+ foreach (var filter in _columnFilters.Values)
+ {
+ if (filter.IsActive && !DataGridFilterService.MatchesFilter(item, filter))
+ return false;
+ }
+ return true;
+ });
+ }
+
+ var list = filtered.ToList();
+ AlertsDataGrid.ItemsSource = list;
+ NoAlertsMessage.Visibility = list.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ AlertCountIndicator.Text = list.Count > 0 ? $"{list.Count} alert(s)" : "";
+
+ /* Populate server filter if needed */
+ PopulateServerFilter();
+ }
+
+ private void PopulateServerFilter()
+ {
+ var servers = _allAlerts
+ .Select(a => a.ServerName)
+ .Where(s => !string.IsNullOrEmpty(s))
+ .Distinct()
+ .OrderBy(s => s)
+ .ToList();
+
+ var currentSelection = ServerFilterComboBox.SelectedIndex > 0
+ ? (ServerFilterComboBox.SelectedItem as ComboBoxItem)?.Content?.ToString()
+ : null;
+
+ /* Only rebuild if the list changed */
+ var existingServers = ServerFilterComboBox.Items
+ .OfType()
+ .Skip(1)
+ .Select(i => i.Content?.ToString())
+ .ToList();
+
+ if (servers.SequenceEqual(existingServers)) return;
+
+ ServerFilterComboBox.SelectionChanged -= ServerFilterComboBox_SelectionChanged;
+
+ while (ServerFilterComboBox.Items.Count > 1)
+ ServerFilterComboBox.Items.RemoveAt(1);
+
+ foreach (var server in servers)
+ {
+ ServerFilterComboBox.Items.Add(new ComboBoxItem { Content = server });
+ }
+
+ /* Restore selection */
+ if (currentSelection != null)
+ {
+ for (int i = 1; i < ServerFilterComboBox.Items.Count; i++)
+ {
+ if ((ServerFilterComboBox.Items[i] as ComboBoxItem)?.Content?.ToString() == currentSelection)
+ {
+ ServerFilterComboBox.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+
+ ServerFilterComboBox.SelectionChanged += ServerFilterComboBox_SelectionChanged;
+ }
+
+ private int GetSelectedHoursBack()
+ {
+ if (TimeRangeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr)
+ {
+ return int.TryParse(tagStr, out var hours) ? hours : 24;
+ }
+ return 24;
+ }
+
+ private static string GetStatusDisplay(AlertLogEntry entry)
+ {
+ if (entry.NotificationType == "email")
+ {
+ if (entry.AlertSent) return "Sent";
+ return !string.IsNullOrEmpty(entry.SendError) ? "Failed" : "Not sent";
+ }
+ return entry.AlertSent ? "Delivered" : "Shown";
+ }
+
+ #region Event Handlers
+
+ private void TimeRangeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (IsLoaded)
+ LoadAlerts();
+ }
+
+ private void ServerFilterComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (IsLoaded)
+ ApplyFilters();
+ }
+
+ private void RefreshButton_Click(object sender, RoutedEventArgs e)
+ {
+ LoadAlerts();
+ }
+
+ private void AlertsDataGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ DismissSelectedButton.IsEnabled = AlertsDataGrid.SelectedItems.Count > 0;
+ }
+
+ private void DismissSelected_Click(object sender, RoutedEventArgs e)
+ {
+ var service = EmailAlertService.Current;
+ if (service == null) return;
+
+ var selected = AlertsDataGrid.SelectedItems
+ .OfType()
+ .ToList();
+
+ if (selected.Count == 0) return;
+
+ var keys = selected
+ .Select(s => (s.AlertTime, s.ServerName, s.MetricName))
+ .ToList();
+
+ service.HideAlerts(keys);
+ LoadAlerts();
+ }
+
+ private void DismissAll_Click(object sender, RoutedEventArgs e)
+ {
+ var service = EmailAlertService.Current;
+ if (service == null) return;
+
+ var displayCount = AlertsDataGrid.ItemsSource is ICollection coll ? coll.Count : 0;
+ if (displayCount == 0) return;
+
+ var result = MessageBox.Show(
+ $"Dismiss all {displayCount} visible alert(s)?\n\nDismissed alerts are hidden from this view but preserved in the log.",
+ "Dismiss All Alerts",
+ MessageBoxButton.YesNo,
+ MessageBoxImage.Question);
+
+ if (result != MessageBoxResult.Yes) return;
+
+ var hoursBack = GetSelectedHoursBack();
+ string? serverName = null;
+ if (ServerFilterComboBox.SelectedIndex > 0 &&
+ ServerFilterComboBox.SelectedItem is ComboBoxItem serverItem)
+ {
+ serverName = serverItem.Content?.ToString();
+ }
+
+ service.HideAllAlerts(hoursBack > 0 ? hoursBack : 8760, serverName);
+ LoadAlerts();
+ }
+
+ #endregion
+
+ #region Column Filter Handlers
+
+ private void AlertFilter_Click(object sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnName) return;
+ ShowFilterPopup(button, columnName);
+ }
+
+ private void ShowFilterPopup(Button button, string columnName)
+ {
+ if (_filterPopup == null)
+ {
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopupContent.FilterApplied += FilterPopup_FilterApplied;
+ _filterPopupContent.FilterCleared += FilterPopup_FilterCleared;
+
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ StaysOpen = false,
+ Placement = PlacementMode.Bottom,
+ AllowsTransparency = true
+ };
+ }
+
+ _columnFilters.TryGetValue(columnName, out var existingFilter);
+ _filterPopupContent!.Initialize(columnName, existingFilter);
+
+ _filterPopup.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void FilterPopup_FilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+
+ if (e.FilterState.IsActive)
+ _columnFilters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _columnFilters.Remove(e.FilterState.ColumnName);
+
+ ApplyFilters();
+ UpdateFilterButtonStyles();
+ }
+
+ private void FilterPopup_FilterCleared(object? sender, EventArgs e)
+ {
+ if (_filterPopup != null)
+ _filterPopup.IsOpen = false;
+ }
+
+ private void UpdateFilterButtonStyles()
+ {
+ foreach (var column in AlertsDataGrid.Columns)
+ {
+ if (column.Header is StackPanel stackPanel)
+ {
+ var filterButton = stackPanel.Children.OfType
diff --git a/Lite/Windows/SettingsWindow.xaml.cs b/Lite/Windows/SettingsWindow.xaml.cs
index 94769564..025fb5da 100644
--- a/Lite/Windows/SettingsWindow.xaml.cs
+++ b/Lite/Windows/SettingsWindow.xaml.cs
@@ -221,6 +221,7 @@ private void CopyMcpCommandButton_Click(object sender, RoutedEventArgs e)
private void LoadAlertSettings()
{
+ MinimizeToTrayCheckBox.IsChecked = App.MinimizeToTray;
AlertsEnabledCheckBox.IsChecked = App.AlertsEnabled;
NotifyConnectionCheckBox.IsChecked = App.NotifyConnectionChanges;
AlertCpuCheckBox.IsChecked = App.AlertCpuEnabled;
@@ -229,11 +230,20 @@ private void LoadAlertSettings()
AlertBlockingThresholdBox.Text = App.AlertBlockingThreshold.ToString();
AlertDeadlockCheckBox.IsChecked = App.AlertDeadlockEnabled;
AlertDeadlockThresholdBox.Text = App.AlertDeadlockThreshold.ToString();
+ AlertPoisonWaitCheckBox.IsChecked = App.AlertPoisonWaitEnabled;
+ AlertPoisonWaitThresholdBox.Text = App.AlertPoisonWaitThresholdMs.ToString();
+ AlertLongRunningQueryCheckBox.IsChecked = App.AlertLongRunningQueryEnabled;
+ AlertLongRunningQueryThresholdBox.Text = App.AlertLongRunningQueryThresholdMinutes.ToString();
+ AlertTempDbSpaceCheckBox.IsChecked = App.AlertTempDbSpaceEnabled;
+ AlertTempDbSpaceThresholdBox.Text = App.AlertTempDbSpaceThresholdPercent.ToString();
+ AlertLongRunningJobCheckBox.IsChecked = App.AlertLongRunningJobEnabled;
+ AlertLongRunningJobMultiplierBox.Text = App.AlertLongRunningJobMultiplier.ToString();
UpdateAlertControlStates();
}
private void SaveAlertSettings()
{
+ App.MinimizeToTray = MinimizeToTrayCheckBox.IsChecked == true;
App.AlertsEnabled = AlertsEnabledCheckBox.IsChecked == true;
App.NotifyConnectionChanges = NotifyConnectionCheckBox.IsChecked == true;
App.AlertCpuEnabled = AlertCpuCheckBox.IsChecked == true;
@@ -245,6 +255,18 @@ private void SaveAlertSettings()
App.AlertDeadlockEnabled = AlertDeadlockCheckBox.IsChecked == true;
if (int.TryParse(AlertDeadlockThresholdBox.Text, out var deadlock) && deadlock > 0)
App.AlertDeadlockThreshold = deadlock;
+ App.AlertPoisonWaitEnabled = AlertPoisonWaitCheckBox.IsChecked == true;
+ if (int.TryParse(AlertPoisonWaitThresholdBox.Text, out var poisonWait) && poisonWait > 0)
+ App.AlertPoisonWaitThresholdMs = poisonWait;
+ App.AlertLongRunningQueryEnabled = AlertLongRunningQueryCheckBox.IsChecked == true;
+ if (int.TryParse(AlertLongRunningQueryThresholdBox.Text, out var lrq) && lrq > 0)
+ App.AlertLongRunningQueryThresholdMinutes = lrq;
+ App.AlertTempDbSpaceEnabled = AlertTempDbSpaceCheckBox.IsChecked == true;
+ if (int.TryParse(AlertTempDbSpaceThresholdBox.Text, out var tempDb) && tempDb > 0 && tempDb <= 100)
+ App.AlertTempDbSpaceThresholdPercent = tempDb;
+ App.AlertLongRunningJobEnabled = AlertLongRunningJobCheckBox.IsChecked == true;
+ if (int.TryParse(AlertLongRunningJobMultiplierBox.Text, out var jobMult) && jobMult >= 2 && jobMult <= 20)
+ App.AlertLongRunningJobMultiplier = jobMult;
var settingsPath = Path.Combine(App.ConfigDirectory, "settings.json");
try
@@ -260,6 +282,7 @@ private void SaveAlertSettings()
root = new JsonObject();
}
+ root["minimize_to_tray"] = App.MinimizeToTray;
root["alerts_enabled"] = App.AlertsEnabled;
root["notify_connection_changes"] = App.NotifyConnectionChanges;
root["alert_cpu_enabled"] = App.AlertCpuEnabled;
@@ -268,6 +291,14 @@ private void SaveAlertSettings()
root["alert_blocking_threshold"] = App.AlertBlockingThreshold;
root["alert_deadlock_enabled"] = App.AlertDeadlockEnabled;
root["alert_deadlock_threshold"] = App.AlertDeadlockThreshold;
+ root["alert_poison_wait_enabled"] = App.AlertPoisonWaitEnabled;
+ root["alert_poison_wait_threshold_ms"] = App.AlertPoisonWaitThresholdMs;
+ root["alert_long_running_query_enabled"] = App.AlertLongRunningQueryEnabled;
+ root["alert_long_running_query_threshold_minutes"] = App.AlertLongRunningQueryThresholdMinutes;
+ root["alert_tempdb_space_enabled"] = App.AlertTempDbSpaceEnabled;
+ root["alert_tempdb_space_threshold_percent"] = App.AlertTempDbSpaceThresholdPercent;
+ root["alert_long_running_job_enabled"] = App.AlertLongRunningJobEnabled;
+ root["alert_long_running_job_multiplier"] = App.AlertLongRunningJobMultiplier;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(settingsPath, root.ToJsonString(options));
@@ -293,6 +324,14 @@ private void UpdateAlertControlStates()
AlertBlockingThresholdBox.IsEnabled = enabled;
AlertDeadlockCheckBox.IsEnabled = enabled;
AlertDeadlockThresholdBox.IsEnabled = enabled;
+ AlertPoisonWaitCheckBox.IsEnabled = enabled;
+ AlertPoisonWaitThresholdBox.IsEnabled = enabled;
+ AlertLongRunningQueryCheckBox.IsEnabled = enabled;
+ AlertLongRunningQueryThresholdBox.IsEnabled = enabled;
+ AlertTempDbSpaceCheckBox.IsEnabled = enabled;
+ AlertTempDbSpaceThresholdBox.IsEnabled = enabled;
+ AlertLongRunningJobCheckBox.IsEnabled = enabled;
+ AlertLongRunningJobMultiplierBox.IsEnabled = enabled;
}
private void LoadSmtpSettings()
diff --git a/Lite/config/settings.json b/Lite/config/settings.json
index d0f98cf0..bb2c682a 100644
--- a/Lite/config/settings.json
+++ b/Lite/config/settings.json
@@ -3,7 +3,7 @@
"archive_retention_days": 90,
"default_time_range_hours": 24,
"theme": "light",
- "minimize_to_tray": false,
+ "minimize_to_tray": true,
"start_collection_on_launch": true,
"mcp_enabled": false,
"mcp_port": 5151
diff --git a/install/04_create_schedule_table.sql b/install/04_create_schedule_table.sql
index f1c1b31d..d73c13b4 100644
--- a/install/04_create_schedule_table.sql
+++ b/install/04_create_schedule_table.sql
@@ -43,39 +43,39 @@ SELECT
FROM
(
VALUES
- (N'wait_stats_collector', 1, 5, 2, 30, N'Wait statistics - high frequency for trending'),
+ (N'wait_stats_collector', 1, 1, 2, 30, N'Wait statistics - high frequency for trending'),
(N'query_stats_collector', 1, 2, 5, 30, N'Plan cache queries - recent activity focused'),
- (N'memory_stats_collector', 1, 5, 2, 30, N'Memory pressure monitoring'),
- (N'memory_pressure_events_collector', 1, 5, 5, 30, N'Ring buffer system events'),
+ (N'memory_stats_collector', 1, 1, 2, 30, N'Memory pressure monitoring'),
+ (N'memory_pressure_events_collector', 1, 1, 5, 30, N'Ring buffer system events'),
(N'system_health_collector', 1, 5, 10, 30, N'System health extended events via sp_HealthParser'),
- (N'blocked_process_xml_collector', 1, 5, 2, 30, N'Fast blocked process XML collection'),
- (N'deadlock_xml_collector', 1, 5, 3, 30, N'Fast deadlock XML collection'),
- (N'process_blocked_process_xml', 1, 5, 5, 30, N'Parse blocked process XML via sp_HumanEventsBlockViewer'),
- (N'blocking_deadlock_analyzer', 1, 5, 5, 30, N'Analyze blocking/deadlock trends and alert on significant increases'),
- (N'process_deadlock_xml', 1, 5, 5, 30, N'Parse deadlock XML via sp_BlitzLock'),
- (N'query_store_collector', 1, 5, 10, 30, N'Query Store data collection'),
- (N'procedure_stats_collector', 1, 5, 10, 30, N'Procedure/trigger/function statistics'),
+ (N'blocked_process_xml_collector', 1, 1, 2, 30, N'Fast blocked process XML collection (chain-triggers parser + analyzer)'),
+ (N'deadlock_xml_collector', 1, 1, 3, 30, N'Fast deadlock XML collection (chain-triggers parser + analyzer)'),
+ (N'process_blocked_process_xml', 1, 5, 5, 30, N'Parse blocked process XML via sp_HumanEventsBlockViewer (also chain-triggered)'),
+ (N'blocking_deadlock_analyzer', 1, 5, 5, 30, N'Analyze blocking/deadlock trends (also chain-triggered)'),
+ (N'process_deadlock_xml', 1, 5, 5, 30, N'Parse deadlock XML via sp_BlitzLock (also chain-triggered)'),
+ (N'query_store_collector', 1, 2, 10, 30, N'Query Store data collection'),
+ (N'procedure_stats_collector', 1, 2, 10, 30, N'Procedure/trigger/function statistics'),
(N'query_snapshots_collector', 1, 1, 2, 10, N'Currently executing queries with session wait stats (every minute - high frequency)'),
- (N'file_io_stats_collector', 1, 5, 2, 30, N'File I/O statistics from dm_io_virtual_file_stats'),
- (N'memory_grant_stats_collector', 1, 5, 2, 30, N'Memory grant semaphore pressure monitoring'),
- (N'cpu_scheduler_stats_collector', 1, 5, 2, 30, N'CPU scheduler and workload group statistics'),
+ (N'file_io_stats_collector', 1, 1, 2, 30, N'File I/O statistics from dm_io_virtual_file_stats'),
+ (N'memory_grant_stats_collector', 1, 1, 2, 30, N'Memory grant semaphore pressure monitoring'),
+ (N'cpu_scheduler_stats_collector', 1, 1, 2, 30, N'CPU scheduler and workload group statistics'),
(N'memory_clerks_stats_collector', 1, 5, 3, 30, N'Memory clerk allocation tracking'),
(N'perfmon_stats_collector', 1, 5, 2, 30, N'Performance counter statistics from dm_os_performance_counters'),
- (N'cpu_utilization_stats_collector', 1, 5, 2, 30, N'CPU utilization from ring buffer (SQL vs other processes)'),
+ (N'cpu_utilization_stats_collector', 1, 1, 2, 30, N'CPU utilization from ring buffer (SQL vs other processes)'),
(N'trace_management_collector', 1, 1440, 5, 30, N'SQL Trace management for long-running queries'),
- (N'trace_analysis_collector', 1, 5, 5, 30, N'Process trace files into analysis tables'),
+ (N'trace_analysis_collector', 1, 2, 5, 30, N'Process trace files into analysis tables'),
(N'default_trace_collector', 1, 5, 3, 30, N'System events from default trace (memory, autogrow, config changes)'),
(N'server_configuration_collector', 1, 1440, 5, 30, N'Server-level configuration settings and trace flags (daily collection)'),
(N'database_configuration_collector', 1, 1440, 10, 30, N'Database-level configuration settings including scoped configs (daily collection)'),
(N'configuration_issues_analyzer', 1, 1, 2, 30, N'Analyze configuration for issues: database config (Query Store, auto shrink/close), memory/CPU pressure warnings, server config (MAXDOP, priority boost)'),
- (N'latch_stats_collector', 1, 5, 3, 30, N'Latch contention statistics - internal synchronization object waits'),
- (N'spinlock_stats_collector', 1, 5, 3, 30, N'Spinlock contention statistics - lightweight synchronization primitive collisions'),
- (N'tempdb_stats_collector', 1, 5, 2, 30, N'TempDB space usage - version store, user/internal objects, allocation contention'),
+ (N'latch_stats_collector', 1, 1, 3, 30, N'Latch contention statistics - internal synchronization object waits'),
+ (N'spinlock_stats_collector', 1, 1, 3, 30, N'Spinlock contention statistics - lightweight synchronization primitive collisions'),
+ (N'tempdb_stats_collector', 1, 1, 2, 30, N'TempDB space usage - version store, user/internal objects, allocation contention'),
(N'plan_cache_stats_collector', 1, 5, 5, 30, N'Plan cache composition statistics - single-use plans and plan cache bloat detection'),
- (N'session_stats_collector', 1, 5, 2, 30, N'Session and connection statistics - connection leaks and application patterns'),
- (N'waiting_tasks_collector', 1, 5, 2, 30, N'Currently waiting tasks - blocking chains and wait analysis'),
+ (N'session_stats_collector', 1, 1, 2, 30, N'Session and connection statistics - connection leaks and application patterns'),
+ (N'waiting_tasks_collector', 1, 1, 2, 30, N'Currently waiting tasks - blocking chains and wait analysis'),
(N'session_wait_stats_collector', 1, 1, 2, 30, N'Per-session wait statistics - correlates waits with specific sessions/queries (requires SQL Server 2016 SP1+)'),
- (N'running_jobs_collector', 1, 5, 2, 7, N'Currently running SQL Agent jobs with historical duration comparison')
+ (N'running_jobs_collector', 1, 1, 2, 7, N'Currently running SQL Agent jobs with historical duration comparison')
) AS v (collector_name, enabled, frequency_minutes, max_duration_minutes, retention_days, description)
WHERE NOT EXISTS
(
diff --git a/install/10_collect_procedure_stats.sql b/install/10_collect_procedure_stats.sql
index fdc86ac4..e2dd1068 100644
--- a/install/10_collect_procedure_stats.sql
+++ b/install/10_collect_procedure_stats.sql
@@ -1,471 +1,561 @@
-/*
-Copyright 2026 Darling Data, LLC
-https://www.erikdarling.com/
-
-*/
-
-SET ANSI_NULLS ON;
-SET ANSI_PADDING ON;
-SET ANSI_WARNINGS ON;
-SET ARITHABORT ON;
-SET CONCAT_NULL_YIELDS_NULL ON;
-SET QUOTED_IDENTIFIER ON;
-SET NUMERIC_ROUNDABORT OFF;
-SET IMPLICIT_TRANSACTIONS OFF;
-SET STATISTICS TIME, IO OFF;
-GO
-
-USE PerformanceMonitor;
-GO
-
-/*
-Procedure, trigger, and function stats collector
-Collects execution statistics from sys.dm_exec_procedure_stats,
-sys.dm_exec_trigger_stats, and sys.dm_exec_function_stats
-Includes execution plans for performance analysis
-*/
-
-IF OBJECT_ID(N'collect.procedure_stats_collector', N'P') IS NULL
-BEGIN
- EXECUTE(N'CREATE PROCEDURE collect.procedure_stats_collector AS RETURN 138;');
-END;
-GO
-
-ALTER PROCEDURE
- collect.procedure_stats_collector
-(
- @debug bit = 0 /*Print debugging information*/
-)
-WITH RECOMPILE
-AS
-BEGIN
- SET NOCOUNT ON;
- SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-
- DECLARE
- @rows_collected bigint = 0,
- @start_time datetime2(7) = SYSDATETIME(),
- @server_start_time datetime2(7),
- @last_collection_time datetime2(7) = NULL,
- @frequency_minutes integer = NULL,
- @cutoff_time datetime2(7) = NULL;
-
- BEGIN TRY
- BEGIN TRANSACTION;
-
- /*
- Get server start time for restart detection
- */
- SELECT
- @server_start_time = osi.sqlserver_start_time
- FROM sys.dm_os_sys_info AS osi;
-
- /*
- Ensure target table exists
- */
- IF OBJECT_ID(N'collect.procedure_stats', N'U') IS NULL
- BEGIN
- /*
- Log missing table before attempting to create
- */
- INSERT INTO
- config.collection_log
- (
- collection_time,
- collector_name,
- collection_status,
- rows_collected,
- duration_ms,
- error_message
- )
- VALUES
- (
- @start_time,
- N'procedure_stats_collector',
- N'TABLE_MISSING',
- 0,
- 0,
- N'Table collect.procedure_stats does not exist, calling ensure procedure'
- );
-
- /*
- Call procedure to create table
- */
- EXECUTE config.ensure_collection_table
- @table_name = N'procedure_stats',
- @debug = @debug;
-
- /*
- Verify table now exists
- */
- IF OBJECT_ID(N'collect.procedure_stats', N'U') IS NULL
- BEGIN
- ROLLBACK TRANSACTION;
- RAISERROR(N'Table collect.procedure_stats still missing after ensure procedure', 16, 1);
- RETURN;
- END;
- END;
-
- /*
- First run detection - collect all procedures if this is the first execution
- */
- IF NOT EXISTS (SELECT 1/0 FROM collect.procedure_stats)
- AND NOT EXISTS (SELECT 1/0 FROM config.collection_log WHERE collector_name = N'procedure_stats_collector')
- BEGIN
- SET @cutoff_time = CONVERT(datetime2(7), '19000101');
-
- IF @debug = 1
- BEGIN
- RAISERROR(N'First run detected - collecting all procedures from sys.dm_exec_procedure_stats', 0, 1) WITH NOWAIT;
- END;
- END;
- ELSE
- BEGIN
- /*
- Get last collection time for this collector
- */
- SELECT
- @last_collection_time = MAX(ps.collection_time)
- FROM collect.procedure_stats AS ps;
-
- /*
- Get collection interval from schedule table
- */
- SELECT
- @frequency_minutes = cs.frequency_minutes
- FROM config.collection_schedule AS cs
- WHERE cs.collector_name = N'procedure_stats_collector'
- AND cs.enabled = 1;
-
- /*
- Calculate cutoff time
- If we have a previous collection, use that time
- Otherwise use the configured interval (or default to 15 minutes)
- */
- SELECT
- @cutoff_time = ISNULL(@last_collection_time,
- DATEADD(MINUTE, -ISNULL(@frequency_minutes, 15), SYSDATETIME()));
- END;
-
- IF @debug = 1
- BEGIN
- DECLARE @cutoff_time_string nvarchar(30) = CONVERT(nvarchar(30), @cutoff_time, 120);
- RAISERROR(N'Collecting procedure stats with cutoff time: %s', 0, 1, @cutoff_time_string) WITH NOWAIT;
- END;
-
- /*
- Collect procedure, trigger, and function statistics
- Single query with UNION ALL to collect from all three DMVs
- */
- INSERT INTO
- collect.procedure_stats
- (
- server_start_time,
- object_type,
- database_name,
- object_id,
- object_name,
- schema_name,
- type_desc,
- sql_handle,
- plan_handle,
- cached_time,
- last_execution_time,
- execution_count,
- total_worker_time,
- min_worker_time,
- max_worker_time,
- total_elapsed_time,
- min_elapsed_time,
- max_elapsed_time,
- total_logical_reads,
- min_logical_reads,
- max_logical_reads,
- total_physical_reads,
- min_physical_reads,
- max_physical_reads,
- total_logical_writes,
- min_logical_writes,
- max_logical_writes,
- total_spills,
- min_spills,
- max_spills,
- query_plan_text
- )
- SELECT
- server_start_time = @server_start_time,
- object_type = N'PROCEDURE',
- database_name = d.name,
- object_id = ps.object_id,
- object_name = OBJECT_NAME(ps.object_id, ps.database_id),
- schema_name = OBJECT_SCHEMA_NAME(ps.object_id, ps.database_id),
- type_desc = N'PROCEDURE',
- sql_handle = ps.sql_handle,
- plan_handle = ps.plan_handle,
- cached_time = ps.cached_time,
- last_execution_time = ps.last_execution_time,
- execution_count = ps.execution_count,
- total_worker_time = ps.total_worker_time,
- min_worker_time = ps.min_worker_time,
- max_worker_time = ps.max_worker_time,
- total_elapsed_time = ps.total_elapsed_time,
- min_elapsed_time = ps.min_elapsed_time,
- max_elapsed_time = ps.max_elapsed_time,
- total_logical_reads = ps.total_logical_reads,
- min_logical_reads = ps.min_logical_reads,
- max_logical_reads = ps.max_logical_reads,
- total_physical_reads = ps.total_physical_reads,
- min_physical_reads = ps.min_physical_reads,
- max_physical_reads = ps.max_physical_reads,
- total_logical_writes = ps.total_logical_writes,
- min_logical_writes = ps.min_logical_writes,
- max_logical_writes = ps.max_logical_writes,
- total_spills = ps.total_spills,
- min_spills = ps.min_spills,
- max_spills = ps.max_spills,
- query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
- FROM sys.dm_exec_procedure_stats AS ps
- OUTER APPLY
- sys.dm_exec_text_query_plan
- (
- ps.plan_handle,
- 0,
- -1
- ) AS tqp
- OUTER APPLY
- (
- SELECT
- dbid = CONVERT(integer, pa.value)
- FROM sys.dm_exec_plan_attributes(ps.plan_handle) AS pa
- WHERE pa.attribute = N'dbid'
- ) AS pa
- LEFT JOIN sys.databases AS d
- ON pa.dbid = d.database_id
- WHERE ps.last_execution_time >= @cutoff_time
- AND pa.dbid NOT IN
- (
- 1, 3, 4, 32761, 32767,
- DB_ID(N'PerformanceMonitor')
- )
- AND pa.dbid < 32761 /*exclude contained AG system databases*/
-
- UNION ALL
-
- SELECT
- server_start_time = @server_start_time,
- object_type = N'TRIGGER',
- database_name = d.name,
- object_id = ts.object_id,
- object_name = COALESCE(
- OBJECT_NAME(ts.object_id, ts.database_id),
- /*Parse trigger name from CREATE TRIGGER [name] or CREATE TRIGGER name*/
- CASE
- WHEN CHARINDEX(N'CREATE TRIGGER', st.text) > 0
- THEN LTRIM(RTRIM(REPLACE(REPLACE(
- SUBSTRING(
- st.text,
- CHARINDEX(N'CREATE TRIGGER', st.text) + 15,
- CHARINDEX(N' ON ', st.text + N' ON ') - CHARINDEX(N'CREATE TRIGGER', st.text) - 15
- ), N'[', N''), N']', N'')))
- ELSE N'trigger_' + CONVERT(nvarchar(20), ts.object_id)
- END
- ),
- schema_name = ISNULL(OBJECT_SCHEMA_NAME(ts.object_id, ts.database_id), N'dbo'),
- type_desc = N'TRIGGER',
- sql_handle = ts.sql_handle,
- plan_handle = ts.plan_handle,
- cached_time = ts.cached_time,
- last_execution_time = ts.last_execution_time,
- execution_count = ts.execution_count,
- total_worker_time = ts.total_worker_time,
- min_worker_time = ts.min_worker_time,
- max_worker_time = ts.max_worker_time,
- total_elapsed_time = ts.total_elapsed_time,
- min_elapsed_time = ts.min_elapsed_time,
- max_elapsed_time = ts.max_elapsed_time,
- total_logical_reads = ts.total_logical_reads,
- min_logical_reads = ts.min_logical_reads,
- max_logical_reads = ts.max_logical_reads,
- total_physical_reads = ts.total_physical_reads,
- min_physical_reads = ts.min_physical_reads,
- max_physical_reads = ts.max_physical_reads,
- total_logical_writes = ts.total_logical_writes,
- min_logical_writes = ts.min_logical_writes,
- max_logical_writes = ts.max_logical_writes,
- total_spills = ts.total_spills,
- min_spills = ts.min_spills,
- max_spills = ts.max_spills,
- query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
- FROM sys.dm_exec_trigger_stats AS ts
- CROSS APPLY sys.dm_exec_sql_text(ts.sql_handle) AS st
- OUTER APPLY
- sys.dm_exec_text_query_plan
- (
- ts.plan_handle,
- 0,
- -1
- ) AS tqp
- OUTER APPLY
- (
- SELECT
- dbid = CONVERT(integer, pa.value)
- FROM sys.dm_exec_plan_attributes(ts.plan_handle) AS pa
- WHERE pa.attribute = N'dbid'
- ) AS pa
- LEFT JOIN sys.databases AS d
- ON pa.dbid = d.database_id
- WHERE ts.last_execution_time >= @cutoff_time
- AND pa.dbid NOT IN
- (
- 1, 3, 4, 32761, 32767,
- DB_ID(N'PerformanceMonitor')
- )
- AND pa.dbid < 32761 /*exclude contained AG system databases*/
- UNION ALL
-
- SELECT
- server_start_time = @server_start_time,
- object_type = N'FUNCTION',
- database_name = d.name,
- object_id = fs.object_id,
- object_name = OBJECT_NAME(fs.object_id, fs.database_id),
- schema_name = OBJECT_SCHEMA_NAME(fs.object_id, fs.database_id),
- type_desc = N'FUNCTION',
- sql_handle = fs.sql_handle,
- plan_handle = fs.plan_handle,
- cached_time = fs.cached_time,
- last_execution_time = fs.last_execution_time,
- execution_count = fs.execution_count,
- total_worker_time = fs.total_worker_time,
- min_worker_time = fs.min_worker_time,
- max_worker_time = fs.max_worker_time,
- total_elapsed_time = fs.total_elapsed_time,
- min_elapsed_time = fs.min_elapsed_time,
- max_elapsed_time = fs.max_elapsed_time,
- total_logical_reads = fs.total_logical_reads,
- min_logical_reads = fs.min_logical_reads,
- max_logical_reads = fs.max_logical_reads,
- total_physical_reads = fs.total_physical_reads,
- min_physical_reads = fs.min_physical_reads,
- max_physical_reads = fs.max_physical_reads,
- total_logical_writes = fs.total_logical_writes,
- min_logical_writes = fs.min_logical_writes,
- max_logical_writes = fs.max_logical_writes,
- total_spills = NULL,
- min_spills = NULL,
- max_spills = NULL,
- query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
- FROM sys.dm_exec_function_stats AS fs
- OUTER APPLY
- sys.dm_exec_text_query_plan
- (
- fs.plan_handle,
- 0,
- -1
- ) AS tqp
- OUTER APPLY
- (
- SELECT
- dbid = CONVERT(integer, pa.value)
- FROM sys.dm_exec_plan_attributes(fs.plan_handle) AS pa
- WHERE pa.attribute = N'dbid'
- ) AS pa
- LEFT JOIN sys.databases AS d
- ON pa.dbid = d.database_id
- WHERE fs.last_execution_time >= @cutoff_time
- AND pa.dbid NOT IN
- (
- 1, 3, 4, 32761, 32767,
- DB_ID(N'PerformanceMonitor')
- )
- AND pa.dbid < 32761 /*exclude contained AG system databases*/
- OPTION(RECOMPILE);
-
- SET @rows_collected = ROWCOUNT_BIG();
-
- /*
- Calculate deltas for the newly inserted data
- */
- EXECUTE collect.calculate_deltas
- @table_name = N'procedure_stats',
- @debug = @debug;
-
- /*Tie statement sto procedures when possible*/
- UPDATE
- qs
- SET
- qs.object_type = ISNULL(ps.object_type,'STATEMENT'),
- qs.schema_name = ISNULL(ps.schema_name, N'N/A'),
- qs.object_name = ISNULL(ps.object_name, N'N/A')
- FROM collect.query_stats AS qs
- LEFT JOIN collect.procedure_stats AS ps
- ON ps.sql_handle = qs.sql_handle
- AND ps.collection_time >= DATEADD(MINUTE, -1, @cutoff_time)
- WHERE qs.object_type = 'STATEMENT'
- AND qs.schema_name IS NULL
- AND qs.object_name IS NULL
- OPTION(RECOMPILE);
-
-
- /*
- Log successful collection
- */
- INSERT INTO
- config.collection_log
- (
- collector_name,
- collection_status,
- rows_collected,
- duration_ms
- )
- VALUES
- (
- N'procedure_stats_collector',
- N'SUCCESS',
- @rows_collected,
- DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
- );
-
- IF @debug = 1
- BEGIN
- RAISERROR(N'Collected %d procedure/trigger/function stats rows', 0, 1, @rows_collected) WITH NOWAIT;
- END;
-
- COMMIT TRANSACTION;
-
- END TRY
- BEGIN CATCH
- IF @@TRANCOUNT > 0
- BEGIN
- ROLLBACK TRANSACTION;
- END;
-
- DECLARE
- @error_message nvarchar(4000) = ERROR_MESSAGE();
-
- /*
- Log the error
- */
- INSERT INTO
- config.collection_log
- (
- collector_name,
- collection_status,
- duration_ms,
- error_message
- )
- VALUES
- (
- N'procedure_stats_collector',
- N'ERROR',
- DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
- @error_message
- );
-
- RAISERROR(N'Error in procedure stats collector: %s', 16, 1, @error_message);
- END CATCH;
-END;
-GO
-
-PRINT 'Procedure stats collector created successfully';
-GO
+/*
+Copyright 2026 Darling Data, LLC
+https://www.erikdarling.com/
+
+*/
+
+SET ANSI_NULLS ON;
+SET ANSI_PADDING ON;
+SET ANSI_WARNINGS ON;
+SET ARITHABORT ON;
+SET CONCAT_NULL_YIELDS_NULL ON;
+SET QUOTED_IDENTIFIER ON;
+SET NUMERIC_ROUNDABORT OFF;
+SET IMPLICIT_TRANSACTIONS OFF;
+SET STATISTICS TIME, IO OFF;
+GO
+
+USE PerformanceMonitor;
+GO
+
+/*
+Procedure, trigger, and function stats collector
+Collects execution statistics from sys.dm_exec_procedure_stats,
+sys.dm_exec_trigger_stats, and sys.dm_exec_function_stats
+Includes execution plans for performance analysis
+*/
+
+IF OBJECT_ID(N'collect.procedure_stats_collector', N'P') IS NULL
+BEGIN
+ EXECUTE(N'CREATE PROCEDURE collect.procedure_stats_collector AS RETURN 138;');
+END;
+GO
+
+ALTER PROCEDURE
+ collect.procedure_stats_collector
+(
+ @debug bit = 0 /*Print debugging information*/
+)
+WITH RECOMPILE
+AS
+BEGIN
+ SET NOCOUNT ON;
+ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+ DECLARE
+ @rows_collected bigint = 0,
+ @start_time datetime2(7) = SYSDATETIME(),
+ @server_start_time datetime2(7),
+ @last_collection_time datetime2(7) = NULL,
+ @frequency_minutes integer = NULL,
+ @cutoff_time datetime2(7) = NULL;
+
+ BEGIN TRY
+ BEGIN TRANSACTION;
+
+ /*
+ Get server start time for restart detection
+ */
+ SELECT
+ @server_start_time = osi.sqlserver_start_time
+ FROM sys.dm_os_sys_info AS osi;
+
+ /*
+ Ensure target table exists
+ */
+ IF OBJECT_ID(N'collect.procedure_stats', N'U') IS NULL
+ BEGIN
+ /*
+ Log missing table before attempting to create
+ */
+ INSERT INTO
+ config.collection_log
+ (
+ collection_time,
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ @start_time,
+ N'procedure_stats_collector',
+ N'TABLE_MISSING',
+ 0,
+ 0,
+ N'Table collect.procedure_stats does not exist, calling ensure procedure'
+ );
+
+ /*
+ Call procedure to create table
+ */
+ EXECUTE config.ensure_collection_table
+ @table_name = N'procedure_stats',
+ @debug = @debug;
+
+ /*
+ Verify table now exists
+ */
+ IF OBJECT_ID(N'collect.procedure_stats', N'U') IS NULL
+ BEGIN
+ ROLLBACK TRANSACTION;
+ RAISERROR(N'Table collect.procedure_stats still missing after ensure procedure', 16, 1);
+ RETURN;
+ END;
+ END;
+
+ /*
+ First run detection - collect all procedures if this is the first execution
+ */
+ IF NOT EXISTS (SELECT 1/0 FROM collect.procedure_stats)
+ AND NOT EXISTS (SELECT 1/0 FROM config.collection_log WHERE collector_name = N'procedure_stats_collector')
+ BEGIN
+ SET @cutoff_time = CONVERT(datetime2(7), '19000101');
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'First run detected - collecting all procedures from sys.dm_exec_procedure_stats', 0, 1) WITH NOWAIT;
+ END;
+ END;
+ ELSE
+ BEGIN
+ /*
+ Get last collection time for this collector
+ */
+ SELECT
+ @last_collection_time = MAX(ps.collection_time)
+ FROM collect.procedure_stats AS ps;
+
+ /*
+ Get collection interval from schedule table
+ */
+ SELECT
+ @frequency_minutes = cs.frequency_minutes
+ FROM config.collection_schedule AS cs
+ WHERE cs.collector_name = N'procedure_stats_collector'
+ AND cs.enabled = 1;
+
+ /*
+ Calculate cutoff time
+ If we have a previous collection, use that time
+ Otherwise use the configured interval (or default to 15 minutes)
+ */
+ SELECT
+ @cutoff_time = ISNULL(@last_collection_time,
+ DATEADD(MINUTE, -ISNULL(@frequency_minutes, 15), SYSDATETIME()));
+ END;
+
+ IF @debug = 1
+ BEGIN
+ DECLARE @cutoff_time_string nvarchar(30) = CONVERT(nvarchar(30), @cutoff_time, 120);
+ RAISERROR(N'Collecting procedure stats with cutoff time: %s', 0, 1, @cutoff_time_string) WITH NOWAIT;
+ END;
+
+ /*
+ Collect procedure, trigger, and function statistics
+ Single query with UNION ALL to collect from all three DMVs
+ */
+ INSERT INTO
+ collect.procedure_stats
+ (
+ server_start_time,
+ object_type,
+ database_name,
+ object_id,
+ object_name,
+ schema_name,
+ type_desc,
+ sql_handle,
+ plan_handle,
+ cached_time,
+ last_execution_time,
+ execution_count,
+ total_worker_time,
+ min_worker_time,
+ max_worker_time,
+ total_elapsed_time,
+ min_elapsed_time,
+ max_elapsed_time,
+ total_logical_reads,
+ min_logical_reads,
+ max_logical_reads,
+ total_physical_reads,
+ min_physical_reads,
+ max_physical_reads,
+ total_logical_writes,
+ min_logical_writes,
+ max_logical_writes,
+ total_spills,
+ min_spills,
+ max_spills,
+ query_plan_text
+ )
+ SELECT
+ server_start_time = @server_start_time,
+ object_type = N'PROCEDURE',
+ database_name = d.name,
+ object_id = ps.object_id,
+ object_name = OBJECT_NAME(ps.object_id, ps.database_id),
+ schema_name = OBJECT_SCHEMA_NAME(ps.object_id, ps.database_id),
+ type_desc = N'PROCEDURE',
+ sql_handle = ps.sql_handle,
+ plan_handle = ps.plan_handle,
+ cached_time = ps.cached_time,
+ last_execution_time = ps.last_execution_time,
+ execution_count = ps.execution_count,
+ total_worker_time = ps.total_worker_time,
+ min_worker_time = ps.min_worker_time,
+ max_worker_time = ps.max_worker_time,
+ total_elapsed_time = ps.total_elapsed_time,
+ min_elapsed_time = ps.min_elapsed_time,
+ max_elapsed_time = ps.max_elapsed_time,
+ total_logical_reads = ps.total_logical_reads,
+ min_logical_reads = ps.min_logical_reads,
+ max_logical_reads = ps.max_logical_reads,
+ total_physical_reads = ps.total_physical_reads,
+ min_physical_reads = ps.min_physical_reads,
+ max_physical_reads = ps.max_physical_reads,
+ total_logical_writes = ps.total_logical_writes,
+ min_logical_writes = ps.min_logical_writes,
+ max_logical_writes = ps.max_logical_writes,
+ total_spills = ps.total_spills,
+ min_spills = ps.min_spills,
+ max_spills = ps.max_spills,
+ query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
+ FROM sys.dm_exec_procedure_stats AS ps
+ OUTER APPLY
+ sys.dm_exec_text_query_plan
+ (
+ ps.plan_handle,
+ 0,
+ -1
+ ) AS tqp
+ OUTER APPLY
+ (
+ SELECT
+ dbid = CONVERT(integer, pa.value)
+ FROM sys.dm_exec_plan_attributes(ps.plan_handle) AS pa
+ WHERE pa.attribute = N'dbid'
+ ) AS pa
+ LEFT JOIN sys.databases AS d
+ ON pa.dbid = d.database_id
+ WHERE ps.last_execution_time >= @cutoff_time
+ AND pa.dbid NOT IN
+ (
+ 1, 3, 4, 32761, 32767,
+ DB_ID(N'PerformanceMonitor')
+ )
+ AND pa.dbid < 32761 /*exclude contained AG system databases*/
+
+ UNION ALL
+
+ SELECT
+ server_start_time = @server_start_time,
+ object_type = N'TRIGGER',
+ database_name = d.name,
+ object_id = ts.object_id,
+ object_name = COALESCE(
+ OBJECT_NAME(ts.object_id, ts.database_id),
+ /*Parse trigger name from trigger definition text.
+ Handles: CREATE TRIGGER, CREATE OR ALTER TRIGGER,
+ DML triggers (ON table), DDL triggers (ON DATABASE/ALL SERVER),
+ and newlines between trigger name and ON clause.*/
+ CONVERT
+ (
+ sysname,
+ CASE
+ WHEN st.text LIKE N'%CREATE OR ALTER TRIGGER%'
+ THEN LTRIM(RTRIM(REPLACE(REPLACE(
+ SUBSTRING
+ (
+ st.text,
+ CHARINDEX(N'CREATE OR ALTER TRIGGER', st.text) + 23,
+ /*Find the earliest delimiter after the trigger name:
+ newline (CR/LF) or ON keyword on same line*/
+ ISNULL
+ (
+ NULLIF
+ (
+ CHARINDEX
+ (
+ CHAR(13),
+ SUBSTRING(st.text, CHARINDEX(N'CREATE OR ALTER TRIGGER', st.text) + 23, 256)
+ ),
+ 0
+ ),
+ ISNULL
+ (
+ NULLIF
+ (
+ CHARINDEX
+ (
+ CHAR(10),
+ SUBSTRING(st.text, CHARINDEX(N'CREATE OR ALTER TRIGGER', st.text) + 23, 256)
+ ),
+ 0
+ ),
+ ISNULL
+ (
+ NULLIF
+ (
+ CHARINDEX
+ (
+ N' ON ',
+ SUBSTRING(st.text, CHARINDEX(N'CREATE OR ALTER TRIGGER', st.text) + 23, 256)
+ ),
+ 0
+ ),
+ 128
+ )
+ )
+ ) - 1
+ ), N'[', N''), N']', N'')))
+ WHEN st.text LIKE N'%CREATE TRIGGER%'
+ THEN LTRIM(RTRIM(REPLACE(REPLACE(
+ SUBSTRING
+ (
+ st.text,
+ CHARINDEX(N'CREATE TRIGGER', st.text) + 15,
+ ISNULL
+ (
+ NULLIF
+ (
+ CHARINDEX
+ (
+ CHAR(13),
+ SUBSTRING(st.text, CHARINDEX(N'CREATE TRIGGER', st.text) + 15, 256)
+ ),
+ 0
+ ),
+ ISNULL
+ (
+ NULLIF
+ (
+ CHARINDEX
+ (
+ CHAR(10),
+ SUBSTRING(st.text, CHARINDEX(N'CREATE TRIGGER', st.text) + 15, 256)
+ ),
+ 0
+ ),
+ ISNULL
+ (
+ NULLIF
+ (
+ CHARINDEX
+ (
+ N' ON ',
+ SUBSTRING(st.text, CHARINDEX(N'CREATE TRIGGER', st.text) + 15, 256)
+ ),
+ 0
+ ),
+ 128
+ )
+ )
+ ) - 1
+ ), N'[', N''), N']', N'')))
+ ELSE N'trigger_' + CONVERT(nvarchar(20), ts.object_id)
+ END
+ )
+ ),
+ schema_name = ISNULL(OBJECT_SCHEMA_NAME(ts.object_id, ts.database_id), N'dbo'),
+ type_desc = N'TRIGGER',
+ sql_handle = ts.sql_handle,
+ plan_handle = ts.plan_handle,
+ cached_time = ts.cached_time,
+ last_execution_time = ts.last_execution_time,
+ execution_count = ts.execution_count,
+ total_worker_time = ts.total_worker_time,
+ min_worker_time = ts.min_worker_time,
+ max_worker_time = ts.max_worker_time,
+ total_elapsed_time = ts.total_elapsed_time,
+ min_elapsed_time = ts.min_elapsed_time,
+ max_elapsed_time = ts.max_elapsed_time,
+ total_logical_reads = ts.total_logical_reads,
+ min_logical_reads = ts.min_logical_reads,
+ max_logical_reads = ts.max_logical_reads,
+ total_physical_reads = ts.total_physical_reads,
+ min_physical_reads = ts.min_physical_reads,
+ max_physical_reads = ts.max_physical_reads,
+ total_logical_writes = ts.total_logical_writes,
+ min_logical_writes = ts.min_logical_writes,
+ max_logical_writes = ts.max_logical_writes,
+ total_spills = ts.total_spills,
+ min_spills = ts.min_spills,
+ max_spills = ts.max_spills,
+ query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
+ FROM sys.dm_exec_trigger_stats AS ts
+ CROSS APPLY sys.dm_exec_sql_text(ts.sql_handle) AS st
+ OUTER APPLY
+ sys.dm_exec_text_query_plan
+ (
+ ts.plan_handle,
+ 0,
+ -1
+ ) AS tqp
+ OUTER APPLY
+ (
+ SELECT
+ dbid = CONVERT(integer, pa.value)
+ FROM sys.dm_exec_plan_attributes(ts.plan_handle) AS pa
+ WHERE pa.attribute = N'dbid'
+ ) AS pa
+ LEFT JOIN sys.databases AS d
+ ON pa.dbid = d.database_id
+ WHERE ts.last_execution_time >= @cutoff_time
+ AND pa.dbid NOT IN
+ (
+ 1, 3, 4, 32761, 32767,
+ DB_ID(N'PerformanceMonitor')
+ )
+ AND pa.dbid < 32761 /*exclude contained AG system databases*/
+ UNION ALL
+
+ SELECT
+ server_start_time = @server_start_time,
+ object_type = N'FUNCTION',
+ database_name = d.name,
+ object_id = fs.object_id,
+ object_name = OBJECT_NAME(fs.object_id, fs.database_id),
+ schema_name = OBJECT_SCHEMA_NAME(fs.object_id, fs.database_id),
+ type_desc = N'FUNCTION',
+ sql_handle = fs.sql_handle,
+ plan_handle = fs.plan_handle,
+ cached_time = fs.cached_time,
+ last_execution_time = fs.last_execution_time,
+ execution_count = fs.execution_count,
+ total_worker_time = fs.total_worker_time,
+ min_worker_time = fs.min_worker_time,
+ max_worker_time = fs.max_worker_time,
+ total_elapsed_time = fs.total_elapsed_time,
+ min_elapsed_time = fs.min_elapsed_time,
+ max_elapsed_time = fs.max_elapsed_time,
+ total_logical_reads = fs.total_logical_reads,
+ min_logical_reads = fs.min_logical_reads,
+ max_logical_reads = fs.max_logical_reads,
+ total_physical_reads = fs.total_physical_reads,
+ min_physical_reads = fs.min_physical_reads,
+ max_physical_reads = fs.max_physical_reads,
+ total_logical_writes = fs.total_logical_writes,
+ min_logical_writes = fs.min_logical_writes,
+ max_logical_writes = fs.max_logical_writes,
+ total_spills = NULL,
+ min_spills = NULL,
+ max_spills = NULL,
+ query_plan_text = CONVERT(nvarchar(max), tqp.query_plan)
+ FROM sys.dm_exec_function_stats AS fs
+ OUTER APPLY
+ sys.dm_exec_text_query_plan
+ (
+ fs.plan_handle,
+ 0,
+ -1
+ ) AS tqp
+ OUTER APPLY
+ (
+ SELECT
+ dbid = CONVERT(integer, pa.value)
+ FROM sys.dm_exec_plan_attributes(fs.plan_handle) AS pa
+ WHERE pa.attribute = N'dbid'
+ ) AS pa
+ LEFT JOIN sys.databases AS d
+ ON pa.dbid = d.database_id
+ WHERE fs.last_execution_time >= @cutoff_time
+ AND pa.dbid NOT IN
+ (
+ 1, 3, 4, 32761, 32767,
+ DB_ID(N'PerformanceMonitor')
+ )
+ AND pa.dbid < 32761 /*exclude contained AG system databases*/
+ OPTION(RECOMPILE);
+
+ SET @rows_collected = ROWCOUNT_BIG();
+
+ /*
+ Calculate deltas for the newly inserted data
+ */
+ EXECUTE collect.calculate_deltas
+ @table_name = N'procedure_stats',
+ @debug = @debug;
+
+ /*Tie statement sto procedures when possible*/
+ UPDATE
+ qs
+ SET
+ qs.object_type = ISNULL(ps.object_type,'STATEMENT'),
+ qs.schema_name = ISNULL(ps.schema_name, N'N/A'),
+ qs.object_name = ISNULL(ps.object_name, N'N/A')
+ FROM collect.query_stats AS qs
+ LEFT JOIN collect.procedure_stats AS ps
+ ON ps.sql_handle = qs.sql_handle
+ AND ps.collection_time >= DATEADD(MINUTE, -1, @cutoff_time)
+ WHERE qs.object_type = 'STATEMENT'
+ AND qs.schema_name IS NULL
+ AND qs.object_name IS NULL
+ OPTION(RECOMPILE);
+
+
+ /*
+ Log successful collection
+ */
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ rows_collected,
+ duration_ms
+ )
+ VALUES
+ (
+ N'procedure_stats_collector',
+ N'SUCCESS',
+ @rows_collected,
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
+ );
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Collected %d procedure/trigger/function stats rows', 0, 1, @rows_collected) WITH NOWAIT;
+ END;
+
+ COMMIT TRANSACTION;
+
+ END TRY
+ BEGIN CATCH
+ IF @@TRANCOUNT > 0
+ BEGIN
+ ROLLBACK TRANSACTION;
+ END;
+
+ DECLARE
+ @error_message nvarchar(4000) = ERROR_MESSAGE();
+
+ /*
+ Log the error
+ */
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'procedure_stats_collector',
+ N'ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ @error_message
+ );
+
+ RAISERROR(N'Error in procedure stats collector: %s', 16, 1, @error_message);
+ END CATCH;
+END;
+GO
+
+PRINT 'Procedure stats collector created successfully';
+GO
diff --git a/install/22_collect_blocked_processes.sql b/install/22_collect_blocked_processes.sql
index 50b2fb8d..5032ad3b 100644
--- a/install/22_collect_blocked_processes.sql
+++ b/install/22_collect_blocked_processes.sql
@@ -162,89 +162,103 @@ BEGIN
IF @is_azure_sql_db = 1
BEGIN
SET @sql = N'
- WITH
- ring_buffer_xml AS
+ DECLARE
+ @ring_buffer TABLE
(
- SELECT
- target_data = TRY_CAST(xet.target_data AS xml)
- FROM sys.dm_xe_database_session_targets AS xet
- JOIN sys.dm_xe_database_sessions AS xes
- ON xes.address = xet.event_session_address
- WHERE xes.name = @session_name
- AND xet.target_name = N''ring_buffer''
- ),
- recent_events AS
+ ring_buffer xml NOT NULL
+ );
+
+ INSERT
+ @ring_buffer
(
- SELECT TOP (1000)
- event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
- blocked_process_xml = evt.query(''.'')
- FROM ring_buffer_xml AS rb
- CROSS APPLY rb.target_data.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt)
- WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
- ORDER BY
- evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
+ ring_buffer
)
+ SELECT
+ ring_xml = TRY_CAST(xet.target_data AS xml)
+ FROM sys.dm_xe_database_session_targets AS xet
+ JOIN sys.dm_xe_database_sessions AS xes
+ ON xes.address = xet.event_session_address
+ WHERE xes.name = @session_name
+ AND xet.target_name = N''ring_buffer''
+ OPTION(RECOMPILE);
+
INSERT INTO
collect.blocked_process_xml
(
event_time,
blocked_process_xml
)
- SELECT
- re.event_time,
- re.blocked_process_xml
- FROM recent_events AS re
- WHERE NOT EXISTS
+ SELECT TOP (1000)
+ event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
+ blocked_process_xml = evt.query(''.'')
+ FROM
+ (
+ SELECT
+ rb.ring_buffer
+ FROM @ring_buffer AS rb
+ ) AS rb
+ CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt)
+ WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
+ AND NOT EXISTS
(
SELECT
1/0
FROM collect.blocked_process_xml AS bx
- WHERE bx.event_time = re.event_time
+ WHERE bx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'')
)
+ ORDER BY
+ evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
OPTION(RECOMPILE);';
END;
ELSE
BEGIN
SET @sql = N'
- WITH
- ring_buffer_xml AS
+ DECLARE
+ @ring_buffer TABLE
(
- SELECT
- target_data = TRY_CAST(xet.target_data AS xml)
- FROM sys.dm_xe_session_targets AS xet
- JOIN sys.dm_xe_sessions AS xes
- ON xes.address = xet.event_session_address
- WHERE xes.name = @session_name
- AND xet.target_name = N''ring_buffer''
- ),
- recent_events AS
+ ring_buffer xml NOT NULL
+ );
+
+ INSERT
+ @ring_buffer
(
- SELECT TOP (1000)
- event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
- blocked_process_xml = evt.query(''.'')
- FROM ring_buffer_xml AS rb
- CROSS APPLY rb.target_data.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt)
- WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
- ORDER BY
- evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
+ ring_buffer
)
+ SELECT
+ ring_xml = TRY_CAST(xet.target_data AS xml)
+ FROM sys.dm_xe_session_targets AS xet
+ JOIN sys.dm_xe_sessions AS xes
+ ON xes.address = xet.event_session_address
+ WHERE xes.name = @session_name
+ AND xet.target_name = N''ring_buffer''
+ OPTION(RECOMPILE);
+
INSERT INTO
collect.blocked_process_xml
(
event_time,
blocked_process_xml
)
- SELECT
- re.event_time,
- re.blocked_process_xml
- FROM recent_events AS re
- WHERE NOT EXISTS
+ SELECT TOP (1000)
+ event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
+ blocked_process_xml = evt.query(''.'')
+ FROM
+ (
+ SELECT
+ rb.ring_buffer
+ FROM @ring_buffer AS rb
+ ) AS rb
+ CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="blocked_process_report"]'') AS q(evt)
+ WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
+ AND NOT EXISTS
(
SELECT
1/0
FROM collect.blocked_process_xml AS bx
- WHERE bx.event_time = re.event_time
+ WHERE bx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'')
)
+ ORDER BY
+ evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
OPTION(RECOMPILE);';
END;
@@ -303,6 +317,64 @@ BEGIN
COMMIT TRANSACTION;
+ /*
+ Chain-trigger: when new blocked process XML is found, immediately
+ parse it and run the analyzer instead of waiting for their next
+ scheduled runs. This eliminates up to 10 minutes of pipeline latency.
+ Parser/analyzer errors are logged but do not fail this collector.
+ */
+ IF @rows_collected > 0
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Chain-triggering blocked process parser and analyzer', 0, 1) WITH NOWAIT;
+ END;
+
+ BEGIN TRY
+ EXECUTE collect.process_blocked_process_xml
+ @debug = @debug;
+ END TRY
+ BEGIN CATCH
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'blocked_process_xml_collector',
+ N'CHAIN_ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ N'Chain-triggered parser failed: ' + ERROR_MESSAGE()
+ );
+ END CATCH;
+
+ BEGIN TRY
+ EXECUTE collect.blocking_deadlock_analyzer
+ @debug = @debug;
+ END TRY
+ BEGIN CATCH
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'blocked_process_xml_collector',
+ N'CHAIN_ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ N'Chain-triggered analyzer failed: ' + ERROR_MESSAGE()
+ );
+ END CATCH;
+ END;
+
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
diff --git a/install/23_process_blocked_process_xml.sql b/install/23_process_blocked_process_xml.sql
index dff182d8..c8090bd0 100644
--- a/install/23_process_blocked_process_xml.sql
+++ b/install/23_process_blocked_process_xml.sql
@@ -50,6 +50,7 @@ BEGIN
@rows_available integer = 0,
@rows_deleted bigint = 0,
@rows_marked bigint = 0,
+ @rows_parsed bigint = 0,
@start_time datetime2(7) = SYSDATETIME(),
@error_message nvarchar(4000),
@error_number integer,
@@ -196,27 +197,48 @@ BEGIN
@debug = @debug;
/*
- Mark raw XML rows as processed
- Only mark the rows in the date range we just processed
+ Verify sp_HumanEventsBlockViewer produced parsed results before marking rows as processed
+ If no results were inserted, leave rows unprocessed so they are retried next run
*/
- UPDATE bx
- SET bx.is_processed = 1
- FROM collect.blocked_process_xml AS bx
- WHERE bx.is_processed = 0
- AND (@start_date IS NULL OR bx.event_time >= @start_date)
- AND (@end_date IS NULL OR bx.event_time <= @end_date);
-
SELECT
- @rows_marked = ROWCOUNT_BIG();
+ @rows_parsed = COUNT_BIG(*)
+ FROM collect.blocking_BlockedProcessReport AS b
+ WHERE b.event_time >= @start_date
+ AND b.event_time <= @end_date
+ OPTION(RECOMPILE);
+
+ IF @rows_parsed > 0
+ BEGIN
+ /*
+ Mark raw XML rows as processed
+ Only mark the rows in the date range we just processed
+ */
+ UPDATE bx
+ SET bx.is_processed = 1
+ FROM collect.blocked_process_xml AS bx
+ WHERE bx.is_processed = 0
+ AND (@start_date IS NULL OR bx.event_time >= @start_date)
+ AND (@end_date IS NULL OR bx.event_time <= @end_date);
- IF @debug = 1
+ SELECT
+ @rows_marked = ROWCOUNT_BIG();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Marked %I64d raw XML rows as processed (%I64d parsed blocking events)', 0, 1, @rows_marked, @rows_parsed) WITH NOWAIT;
+ END;
+ END;
+ ELSE
BEGIN
- RAISERROR(N'Marked %I64d raw XML rows as processed', 0, 1, @rows_marked) WITH NOWAIT;
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'sp_HumanEventsBlockViewer produced 0 parsed results for %d XML events - rows left unprocessed for retry', 0, 1, @rows_available) WITH NOWAIT;
+ END;
END;
END;
/*
- Log successful processing
+ Log processing result
*/
INSERT INTO
config.collection_log
@@ -224,19 +246,29 @@ BEGIN
collector_name,
collection_status,
rows_collected,
- duration_ms
+ duration_ms,
+ error_message
)
VALUES
(
N'process_blocked_process_xml',
- N'SUCCESS',
+ CASE WHEN @rows_available = 0 THEN N'SUCCESS'
+ WHEN @rows_parsed > 0 THEN N'SUCCESS'
+ ELSE N'NO_RESULTS'
+ END,
@rows_available,
- DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ CASE WHEN @rows_available > 0 AND @rows_parsed = 0
+ THEN N'sp_HumanEventsBlockViewer returned 0 parsed results for '
+ + CAST(@rows_available AS nvarchar(20))
+ + N' XML events - rows left unprocessed for retry'
+ ELSE NULL
+ END
);
IF @debug = 1
BEGIN
- RAISERROR(N'Processed %d blocked process XML events', 0, 1, @rows_available) WITH NOWAIT;
+ RAISERROR(N'Processed %d blocked process XML events (%I64d parsed results)', 0, 1, @rows_available, @rows_parsed) WITH NOWAIT;
END;
COMMIT TRANSACTION;
diff --git a/install/24_collect_deadlock_xml.sql b/install/24_collect_deadlock_xml.sql
index 17598e47..6bf8f877 100644
--- a/install/24_collect_deadlock_xml.sql
+++ b/install/24_collect_deadlock_xml.sql
@@ -123,89 +123,103 @@ BEGIN
IF @is_azure_sql_db = 1
BEGIN
SET @sql = N'
- WITH
- ring_buffer_xml AS
+ DECLARE
+ @ring_buffer TABLE
(
- SELECT
- target_data = TRY_CAST(xet.target_data AS xml)
- FROM sys.dm_xe_database_session_targets AS xet
- JOIN sys.dm_xe_database_sessions AS xes
- ON xes.address = xet.event_session_address
- WHERE xes.name = @session_name
- AND xet.target_name = N''ring_buffer''
- ),
- recent_events AS
+ ring_buffer xml NOT NULL
+ );
+
+ INSERT
+ @ring_buffer
(
- SELECT TOP (1000)
- event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
- deadlock_xml = evt.query(''.'')
- FROM ring_buffer_xml AS rb
- CROSS APPLY rb.target_data.nodes(''RingBufferTarget/event[@name="xml_deadlock_report"]'') AS q(evt)
- WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
- ORDER BY
- evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
+ ring_buffer
)
+ SELECT
+ ring_xml = TRY_CAST(xet.target_data AS xml)
+ FROM sys.dm_xe_database_session_targets AS xet
+ JOIN sys.dm_xe_database_sessions AS xes
+ ON xes.address = xet.event_session_address
+ WHERE xes.name = @session_name
+ AND xet.target_name = N''ring_buffer''
+ OPTION(RECOMPILE);
+
INSERT INTO
collect.deadlock_xml
(
event_time,
deadlock_xml
)
- SELECT
- re.event_time,
- re.deadlock_xml
- FROM recent_events AS re
- WHERE NOT EXISTS
+ SELECT TOP (1000)
+ event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
+ deadlock_xml = evt.query(''.'')
+ FROM
+ (
+ SELECT
+ rb.ring_buffer
+ FROM @ring_buffer AS rb
+ ) AS rb
+ CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="xml_deadlock_report"]'') AS q(evt)
+ WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
+ AND NOT EXISTS
(
SELECT
1/0
FROM collect.deadlock_xml AS dx
- WHERE dx.event_time = re.event_time
+ WHERE dx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'')
)
+ ORDER BY
+ evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
OPTION(RECOMPILE);';
END;
ELSE
BEGIN
SET @sql = N'
- WITH
- ring_buffer_xml AS
+ DECLARE
+ @ring_buffer TABLE
(
- SELECT
- target_data = TRY_CAST(xet.target_data AS xml)
- FROM sys.dm_xe_session_targets AS xet
- JOIN sys.dm_xe_sessions AS xes
- ON xes.address = xet.event_session_address
- WHERE xes.name = @session_name
- AND xet.target_name = N''ring_buffer''
- ),
- recent_events AS
+ ring_buffer xml NOT NULL
+ );
+
+ INSERT
+ @ring_buffer
(
- SELECT TOP (1000)
- event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
- deadlock_xml = evt.query(''.'')
- FROM ring_buffer_xml AS rb
- CROSS APPLY rb.target_data.nodes(''RingBufferTarget/event[@name="xml_deadlock_report"]'') AS q(evt)
- WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
- ORDER BY
- evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
+ ring_buffer
)
+ SELECT
+ ring_xml = TRY_CAST(xet.target_data AS xml)
+ FROM sys.dm_xe_session_targets AS xet
+ JOIN sys.dm_xe_sessions AS xes
+ ON xes.address = xet.event_session_address
+ WHERE xes.name = @session_name
+ AND xet.target_name = N''ring_buffer''
+ OPTION(RECOMPILE);
+
INSERT INTO
collect.deadlock_xml
(
event_time,
deadlock_xml
)
- SELECT
- re.event_time,
- re.deadlock_xml
- FROM recent_events AS re
- WHERE NOT EXISTS
+ SELECT TOP (1000)
+ event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)''),
+ deadlock_xml = evt.query(''.'')
+ FROM
+ (
+ SELECT
+ rb.ring_buffer
+ FROM @ring_buffer AS rb
+ ) AS rb
+ CROSS APPLY rb.ring_buffer.nodes(''RingBufferTarget/event[@name="xml_deadlock_report"]'') AS q(evt)
+ WHERE evt.value(''(@timestamp)[1]'', ''datetime2(7)'') >= @cutoff_time
+ AND NOT EXISTS
(
SELECT
1/0
FROM collect.deadlock_xml AS dx
- WHERE dx.event_time = re.event_time
+ WHERE dx.event_time = evt.value(''(@timestamp)[1]'', ''datetime2(7)'')
)
+ ORDER BY
+ evt.value(''(@timestamp)[1]'', ''datetime2(7)'') DESC
OPTION(RECOMPILE);';
END;
@@ -264,6 +278,64 @@ BEGIN
COMMIT TRANSACTION;
+ /*
+ Chain-trigger: when new deadlock XML is found, immediately
+ parse it and run the analyzer instead of waiting for their next
+ scheduled runs. This eliminates up to 10 minutes of pipeline latency.
+ Parser/analyzer errors are logged but do not fail this collector.
+ */
+ IF @rows_collected > 0
+ BEGIN
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Chain-triggering deadlock parser and analyzer', 0, 1) WITH NOWAIT;
+ END;
+
+ BEGIN TRY
+ EXECUTE collect.process_deadlock_xml
+ @debug = @debug;
+ END TRY
+ BEGIN CATCH
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'deadlock_xml_collector',
+ N'CHAIN_ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ N'Chain-triggered parser failed: ' + ERROR_MESSAGE()
+ );
+ END CATCH;
+
+ BEGIN TRY
+ EXECUTE collect.blocking_deadlock_analyzer
+ @debug = @debug;
+ END TRY
+ BEGIN CATCH
+ INSERT INTO
+ config.collection_log
+ (
+ collector_name,
+ collection_status,
+ duration_ms,
+ error_message
+ )
+ VALUES
+ (
+ N'deadlock_xml_collector',
+ N'CHAIN_ERROR',
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ N'Chain-triggered analyzer failed: ' + ERROR_MESSAGE()
+ );
+ END CATCH;
+ END;
+
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
diff --git a/install/25_process_deadlock_xml.sql b/install/25_process_deadlock_xml.sql
index 91fb1974..b7e75850 100644
--- a/install/25_process_deadlock_xml.sql
+++ b/install/25_process_deadlock_xml.sql
@@ -49,6 +49,7 @@ BEGIN
@rows_available integer = 0,
@rows_deleted bigint = 0,
@rows_marked bigint = 0,
+ @rows_parsed bigint = 0,
@start_time datetime2(7) = SYSDATETIME(),
@error_message nvarchar(4000),
@error_number integer,
@@ -128,7 +129,7 @@ BEGIN
BEGIN
SELECT
@start_date = MIN(dx.event_time),
- @end_date = MAX(dx.event_time)
+ @end_date = DATEADD(SECOND, 1, MAX(dx.event_time))
FROM collect.deadlock_xml AS dx
WHERE dx.is_processed = 0
AND dx.event_time IS NOT NULL
@@ -189,27 +190,48 @@ BEGIN
@debug = @debug;
/*
- Mark raw XML rows as processed
- Only mark the rows in the date range we just processed
+ Verify sp_BlitzLock produced parsed results before marking rows as processed
+ If no results were inserted, leave rows unprocessed so they are retried next run
*/
- UPDATE dx
- SET dx.is_processed = 1
- FROM collect.deadlock_xml AS dx
- WHERE dx.is_processed = 0
- AND (@start_date IS NULL OR dx.event_time >= @start_date)
- AND (@end_date IS NULL OR dx.event_time <= @end_date);
-
SELECT
- @rows_marked = ROWCOUNT_BIG();
+ @rows_parsed = COUNT_BIG(*)
+ FROM collect.deadlocks AS d
+ WHERE d.event_date >= @start_date
+ AND d.event_date <= @end_date
+ OPTION(RECOMPILE);
+
+ IF @rows_parsed > 0
+ BEGIN
+ /*
+ Mark raw XML rows as processed
+ Only mark the rows in the date range we just processed
+ */
+ UPDATE dx
+ SET dx.is_processed = 1
+ FROM collect.deadlock_xml AS dx
+ WHERE dx.is_processed = 0
+ AND (@start_date IS NULL OR dx.event_time >= @start_date)
+ AND (@end_date IS NULL OR dx.event_time <= @end_date);
- IF @debug = 1
+ SELECT
+ @rows_marked = ROWCOUNT_BIG();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Marked %I64d raw XML rows as processed (%I64d parsed deadlocks)', 0, 1, @rows_marked, @rows_parsed) WITH NOWAIT;
+ END;
+ END;
+ ELSE
BEGIN
- RAISERROR(N'Marked %I64d raw XML rows as processed', 0, 1, @rows_marked) WITH NOWAIT;
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'sp_BlitzLock produced 0 parsed results for %d XML events - rows left unprocessed for retry', 0, 1, @rows_available) WITH NOWAIT;
+ END;
END;
END;
/*
- Log successful processing
+ Log processing result
*/
INSERT INTO
config.collection_log
@@ -217,19 +239,29 @@ BEGIN
collector_name,
collection_status,
rows_collected,
- duration_ms
+ duration_ms,
+ error_message
)
VALUES
(
N'process_deadlock_xml',
- N'SUCCESS',
+ CASE WHEN @rows_available = 0 THEN N'SUCCESS'
+ WHEN @rows_parsed > 0 THEN N'SUCCESS'
+ ELSE N'NO_RESULTS'
+ END,
@rows_available,
- DATEDIFF(MILLISECOND, @start_time, SYSDATETIME())
+ DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()),
+ CASE WHEN @rows_available > 0 AND @rows_parsed = 0
+ THEN N'sp_BlitzLock returned 0 parsed results for '
+ + CAST(@rows_available AS nvarchar(20))
+ + N' XML events - rows left unprocessed for retry'
+ ELSE NULL
+ END
);
IF @debug = 1
BEGIN
- RAISERROR(N'Processed %d deadlock XML events', 0, 1, @rows_available) WITH NOWAIT;
+ RAISERROR(N'Processed %d deadlock XML events (%I64d parsed results)', 0, 1, @rows_available, @rows_parsed) WITH NOWAIT;
END;
COMMIT TRANSACTION;
diff --git a/install/26_blocking_deadlock_analyzer.sql b/install/26_blocking_deadlock_analyzer.sql
index b02a8fae..d8dfe243 100644
--- a/install/26_blocking_deadlock_analyzer.sql
+++ b/install/26_blocking_deadlock_analyzer.sql
@@ -102,7 +102,7 @@ BEGIN
RAISERROR(N'Aggregating blocking and deadlock events from the last %d hour(s)', 0, 1, @lookback_hours) WITH NOWAIT;
RAISERROR(N'Blocking: counting events from %s', 0, 1, @blocking_range) WITH NOWAIT;
- RAISERROR(N'Deadlock: counting events from %s (interval-based)', 0, 1, @deadlock_range) WITH NOWAIT;
+ RAISERROR(N'Deadlock: counting events collected from %s (by collection_time)', 0, 1, @deadlock_range) WITH NOWAIT;
END;
/*
@@ -188,8 +188,8 @@ BEGIN
total_deadlock_wait_time_ms = SUM(bl.wait_time),
victim_count = SUM(CASE WHEN bl.deadlock_group LIKE N'%- VICTIM' THEN 1 ELSE 0 END)
FROM collect.deadlocks AS bl
- WHERE bl.event_date >= @last_deadlock_collection
- AND bl.event_date < @start_time
+ WHERE bl.collection_time >= @last_deadlock_collection
+ AND bl.collection_time < @start_time
GROUP BY
bl.database_name
)
diff --git a/install/28_collect_system_health_wrapper.sql b/install/28_collect_system_health_wrapper.sql
index 82d63a5f..1c2b7fc2 100644
--- a/install/28_collect_system_health_wrapper.sql
+++ b/install/28_collect_system_health_wrapper.sql
@@ -36,6 +36,8 @@ ALTER PROCEDURE
@what_to_check varchar(10) = 'all', /*What portion of data to collect (all, waits, cpu, memory, disk, system, locking)*/
@hours_back integer = 1, /*How many hours back to analyze*/
@warnings_only bit = 1, /*Only collect data from recorded warnings*/
+ @skip_locks bit = 1, /*Skip the blocking and deadlocks section*/
+ @skip_waits bit = 1, /*Skip the wait stats section*/
@log_retention_days integer = 30, /*Days to retain sp_HealthParser data*/
@procedure_database sysname = NULL, /*Database where sp_HealthParser is installed (NULL = search PerformanceMonitor then master)*/
@debug bit = 0 /*Print debugging information*/
@@ -164,6 +166,8 @@ BEGIN
@start_date = @start_date,
@end_date = @end_date,
@warnings_only = @warnings_only,
+ @skip_locks = @skip_locks,
+ @skip_waits = @skip_waits,
@log_to_table = 1,
@log_database_name = N''PerformanceMonitor'',
@log_schema_name = N''collect'',
@@ -173,11 +177,13 @@ BEGIN
EXECUTE sys.sp_executesql
@sql,
- N'@what_to_check varchar(10), @start_date datetimeoffset(7), @end_date datetimeoffset(7), @warnings_only bit, @log_retention_days integer, @debug bit',
+ N'@what_to_check varchar(10), @start_date datetimeoffset(7), @end_date datetimeoffset(7), @warnings_only bit, @skip_locks bit, @skip_waits bit, @log_retention_days integer, @debug bit',
@what_to_check = @what_to_check,
@start_date = @start_date,
@end_date = @end_date,
@warnings_only = @warnings_only,
+ @skip_locks = @skip_locks,
+ @skip_waits = @skip_waits,
@log_retention_days = @log_retention_days,
@debug = @debug;
diff --git a/install/42_scheduled_master_collector.sql b/install/42_scheduled_master_collector.sql
index f853dd3c..791a4953 100644
--- a/install/42_scheduled_master_collector.sql
+++ b/install/42_scheduled_master_collector.sql
@@ -53,6 +53,7 @@ BEGIN
@schedule_id integer,
@frequency_minutes integer,
@max_duration_minutes integer,
+ @minutes_back integer,
@error_message nvarchar(4000);
BEGIN TRY
@@ -175,7 +176,8 @@ BEGIN
WHILE @@FETCH_STATUS = 0
BEGIN
SET @collector_start_time = SYSDATETIME();
-
+ SET @minutes_back = @frequency_minutes * 2;
+
IF @debug = 1
BEGIN
RAISERROR(N'Running collector: %s (frequency: %d minutes)', 0, 1, @collector_name, @frequency_minutes) WITH NOWAIT;
@@ -207,7 +209,7 @@ BEGIN
END;
ELSE IF @collector_name = N'blocked_process_xml_collector'
BEGIN
- EXECUTE collect.blocked_process_xml_collector @debug = @debug;
+ EXECUTE collect.blocked_process_xml_collector @minutes_back = @minutes_back, @debug = @debug;
END;
ELSE IF @collector_name = N'process_blocked_process_xml'
BEGIN
@@ -215,7 +217,7 @@ BEGIN
END;
ELSE IF @collector_name = N'deadlock_xml_collector'
BEGIN
- EXECUTE collect.deadlock_xml_collector @debug = @debug;
+ EXECUTE collect.deadlock_xml_collector @minutes_back = @minutes_back, @debug = @debug;
END;
ELSE IF @collector_name = N'process_deadlock_xml'
BEGIN