diff --git a/Directory.Build.props b/Directory.Build.props
index 9a51e2f..e1fec25 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,8 +8,8 @@
latest-recommended
true
true
-
- $(NoWarn);CS1591
+
+ $(NoWarn);CS1591;CS1573
SEOSoft
SQLInsight Pro
Copyright (c) SEOSoft
diff --git a/SQLInsight.slnx b/SQLInsight.slnx
index c8a68d7..28b3969 100644
--- a/SQLInsight.slnx
+++ b/SQLInsight.slnx
@@ -1,6 +1,7 @@
+
@@ -9,6 +10,7 @@
+
diff --git a/src/SQLInsight.Collectors/BuiltInCollectors.cs b/src/SQLInsight.Collectors/BuiltInCollectors.cs
new file mode 100644
index 0000000..d4f7421
--- /dev/null
+++ b/src/SQLInsight.Collectors/BuiltInCollectors.cs
@@ -0,0 +1,25 @@
+using SQLInsight.Collectors.Concurrency;
+using SQLInsight.Collectors.Databases;
+using SQLInsight.Collectors.Instance;
+using SQLInsight.Collectors.Performance;
+
+namespace SQLInsight.Collectors;
+
+public static class BuiltInCollectors
+{
+ public static IReadOnlyList Create() =>
+ [
+ new ServerCollector(),
+ new ConfigurationCollector(),
+ new TraceFlagCollector(),
+ new DatabaseCollector(),
+ new SuspectPageCollector(),
+ new PlanCacheCollector(),
+ new WaitStatsCollector(),
+ new FileIoCollector(),
+ new PerformanceCounterCollector(),
+ new CpuCollector(),
+ new BlockingCollector(),
+ new DeadlockCollector(),
+ ];
+}
diff --git a/src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs b/src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs
new file mode 100644
index 0000000..8ffcb40
--- /dev/null
+++ b/src/SQLInsight.Collectors/Concurrency/BlockingCollector.cs
@@ -0,0 +1,79 @@
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Instances;
+
+namespace SQLInsight.Collectors.Concurrency;
+
+///
+/// Blocked sessions and their blockers right now. Head blockers are often sleeping sessions with an open
+/// transaction, which have no row in sys.dm_exec_requests, so sessions are the starting point.
+///
+public sealed class BlockingCollector : Collector
+{
+ public override string Name => "blocking";
+ public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(1);
+
+ protected override async Task CollectAsync(CollectorContext context, BlockingSnapshot? previous)
+ {
+ var version = context.Instance.Version;
+ var sessions = version.Major >= 9
+ ? await Db.QueryAsync(context.Connection, BuildSql2005(version), Map2005, context.CancellationToken).ConfigureAwait(false)
+ : await Db.QueryAsync(context.Connection, Sql2000, Map2000, context.CancellationToken).ConfigureAwait(false);
+
+ return new BlockingSnapshot { CapturedAt = context.Now, Sessions = sessions };
+ }
+
+ internal static string BuildSql2005(SqlServerVersion version)
+ {
+ var openTran = version.Major >= 11
+ ? "s.open_transaction_count"
+ : "(SELECT COUNT(*) FROM sys.dm_tran_session_transactions st WHERE st.session_id = s.session_id)";
+ var database = version.Major >= 11 ? "COALESCE(r.database_id, s.database_id)" : "r.database_id";
+
+ // blocking_session_id < 0 are special cases (orphaned DTC, deferred recovery, latch) and not a session.
+ return $"""
+ SELECT s.session_id, NULLIF(r.blocking_session_id, 0), COALESCE(r.status, s.status), r.wait_type, r.wait_time, r.wait_resource,
+ DB_NAME({database}), s.login_name, s.host_name, s.program_name, {openTran},
+ LEFT(CASE WHEN r.sql_handle IS NOT NULL AND r.statement_start_offset IS NOT NULL
+ THEN SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
+ (CASE WHEN r.statement_end_offset = -1 THEN DATALENGTH(t.text) ELSE r.statement_end_offset END
+ - r.statement_start_offset) / 2 + 1)
+ ELSE t.text END, 4000),
+ s.last_request_end_time
+ FROM sys.dm_exec_sessions s
+ LEFT JOIN sys.dm_exec_requests r ON r.session_id = s.session_id
+ LEFT JOIN sys.dm_exec_connections c ON c.session_id = s.session_id
+ OUTER APPLY sys.dm_exec_sql_text(COALESCE(r.sql_handle, c.most_recent_sql_handle)) t
+ WHERE r.blocking_session_id > 0
+ OR s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id > 0)
+ """;
+ }
+
+ // blocked = spid is a session waiting on its own latch, not blocking.
+ internal const string Sql2000 = """
+ SELECT p.spid, NULLIF(CASE WHEN p.blocked = p.spid THEN 0 ELSE p.blocked END, 0), RTRIM(p.status), RTRIM(p.lastwaittype),
+ p.waittime, RTRIM(p.waitresource), DB_NAME(p.dbid), RTRIM(p.loginame), RTRIM(p.hostname), RTRIM(p.program_name),
+ p.open_tran, CAST(NULL AS nvarchar(4000)), p.last_batch
+ FROM master.dbo.sysprocesses p
+ WHERE (p.blocked > 0 AND p.blocked <> p.spid)
+ OR p.spid IN (SELECT blocked FROM master.dbo.sysprocesses WHERE blocked > 0 AND blocked <> spid)
+ """;
+
+ private static SessionInfo Map2005(Row r) => new()
+ {
+ SessionId = r.Int(0),
+ BlockingSessionId = r.NullableInt(1),
+ Status = r.NullableString(2),
+ WaitType = r.NullableString(3),
+ WaitTimeMs = r.NullableLong(4),
+ WaitResource = r.NullableString(5),
+ DatabaseName = r.NullableString(6),
+ LoginName = r.NullableString(7),
+ HostName = r.NullableString(8),
+ ProgramName = r.NullableString(9),
+ OpenTransactionCount = r.NullableInt(10) ?? 0,
+ SqlText = r.NullableString(11),
+ LastRequestEndTime = r.NullableDateTime(12),
+ };
+
+ private static SessionInfo Map2000(Row r) => Map2005(r);
+}
diff --git a/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs b/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs
new file mode 100644
index 0000000..dbbf2aa
--- /dev/null
+++ b/src/SQLInsight.Collectors/Concurrency/DeadlockCollector.cs
@@ -0,0 +1,192 @@
+using System.Data.Common;
+using System.Globalization;
+using System.Xml.Linq;
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Instances;
+
+namespace SQLInsight.Collectors.Concurrency;
+
+///
+/// Reads new deadlock graphs from the built-in system_health session. From 2012 the session writes to
+/// files (up to 1 GB on 2022), so reading continues from the last file and offset instead of rescanning.
+/// system_health dispatches events with up to 120 seconds latency, so a deadlock can show up one collection late.
+///
+public sealed class DeadlockCollector : Collector
+{
+ /// On the first run, only deadlocks this recent are reported.
+ public static readonly TimeSpan InitialLookback = TimeSpan.FromDays(1);
+
+ public override string Name => "deadlocks";
+ public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(5);
+ public override IReadOnlyCollection RequiredCapabilities => [Capability.SystemHealthSession];
+
+ protected override async Task CollectAsync(CollectorContext context, DeadlockSnapshot? previous)
+ {
+ var since = previous?.LastEventTime ?? context.Now - InitialLookback;
+
+ if (!context.Capabilities.Has(Capability.SystemHealthFileTarget))
+ {
+ return await ReadRingBufferAsync(context, since).ConfigureAwait(false);
+ }
+
+ try
+ {
+ return await ReadFilesAsync(context, since, previous?.LastFileName, previous?.LastFileOffset).ConfigureAwait(false);
+ }
+ catch (DbException) when (previous?.LastFileName is not null)
+ {
+ // The file we stopped in has rolled over and been deleted. Read everything once more.
+ return await ReadFilesAsync(context, since, null, null).ConfigureAwait(false);
+ }
+ }
+
+ internal static string BuildFileSql(string? lastFile, long? lastOffset)
+ {
+ var start = lastFile is not null && lastOffset is not null
+ ? $"{Db.Literal(lastFile)}, {lastOffset.Value.ToString(CultureInfo.InvariantCulture)}"
+ : "NULL, NULL";
+
+ // Only the deadlock rows carry their XML; the rest are kept just to know where reading stopped.
+ return $"""
+ SELECT object_name, file_name, file_offset,
+ CASE WHEN object_name = N'xml_deadlock_report' THEN CAST(event_data AS nvarchar(max)) END AS event_data
+ INTO #system_health
+ FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, {start});
+
+ SELECT event_data FROM #system_health WHERE event_data IS NOT NULL;
+ SELECT TOP (1) file_name, file_offset FROM #system_health ORDER BY file_name DESC, file_offset DESC;
+ DROP TABLE #system_health;
+ """;
+ }
+
+ internal const string RingBufferSql = """
+ SELECT CAST(t.target_data AS nvarchar(max))
+ FROM sys.dm_xe_session_targets t
+ JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
+ WHERE s.name = N'system_health' AND t.target_name = N'ring_buffer'
+ """;
+
+ private static async Task ReadFilesAsync(CollectorContext context, DateTimeOffset since, string? lastFile, long? lastOffset)
+ {
+ var ct = context.CancellationToken;
+ await using var command = context.Connection.CreateCommand();
+ command.CommandText = BuildFileSql(lastFile, lastOffset);
+ command.CommandTimeout = 120;
+
+ var events = new List();
+ string? file = lastFile;
+ long? offset = lastOffset;
+ await using (var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false))
+ {
+ while (await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ events.Add(reader.GetString(0));
+ }
+
+ if (await reader.NextResultAsync(ct).ConfigureAwait(false) && await reader.ReadAsync(ct).ConfigureAwait(false))
+ {
+ file = reader.GetString(0);
+ offset = Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture);
+ }
+ }
+
+ var deadlocks = events
+ .Select(e => DeadlockParser.ParseEvent(XElement.Parse(e)))
+ .OfType()
+ .Where(d => d.Time > since)
+ .OrderBy(d => d.Time)
+ .ToList();
+
+ return new DeadlockSnapshot
+ {
+ CapturedAt = context.Now,
+ NewDeadlocks = deadlocks,
+ LastEventTime = deadlocks.Count > 0 ? deadlocks[^1].Time : since,
+ LastFileName = file,
+ LastFileOffset = offset,
+ };
+ }
+
+ private static async Task ReadRingBufferAsync(CollectorContext context, DateTimeOffset since)
+ {
+ var rows = await Db.QueryAsync(context.Connection, RingBufferSql, r => r.NullableString(0), context.CancellationToken).ConfigureAwait(false);
+ var deadlocks = new List();
+ if (rows.FirstOrDefault() is { } xml)
+ {
+ deadlocks = XElement.Parse(xml).Elements("event")
+ .Where(e => (string?)e.Attribute("name") == "xml_deadlock_report")
+ .Select(DeadlockParser.ParseEvent)
+ .OfType()
+ .Where(d => d.Time > since)
+ .OrderBy(d => d.Time)
+ .ToList();
+ }
+
+ return new DeadlockSnapshot
+ {
+ CapturedAt = context.Now,
+ NewDeadlocks = deadlocks,
+ LastEventTime = deadlocks.Count > 0 ? deadlocks[^1].Time : since,
+ };
+ }
+}
+
+///
+/// Turns an xml_deadlock_report event into a .
+///
+internal static class DeadlockParser
+{
+ public static DeadlockEvent? ParseEvent(XElement evt)
+ {
+ var timestamp = (string?)evt.Attribute("timestamp");
+ var value = evt.Elements("data").FirstOrDefault(d => (string?)d.Attribute("name") == "xml_report")?.Element("value");
+ if (timestamp is null || value is null)
+ {
+ return null;
+ }
+
+ // SQL Server 2008 RTM–SP1 wrote the graph as escaped text instead of XML.
+ var deadlock = value.Element("deadlock") ?? TryParse(value.Value)?.DescendantsAndSelf("deadlock").FirstOrDefault();
+ if (deadlock is null)
+ {
+ return null;
+ }
+
+ var victims = deadlock.Element("victim-list")?.Elements("victimProcess").Select(v => (string?)v.Attribute("id")).ToHashSet()
+ ?? [(string?)deadlock.Attribute("victim")];
+
+ var processes = deadlock.Element("process-list")?.Elements("process").Select(p => new DeadlockProcess
+ {
+ Id = (string?)p.Attribute("id") ?? "?",
+ IsVictim = victims.Contains((string?)p.Attribute("id")),
+ SessionId = (int?)p.Attribute("spid"),
+ DatabaseName = (string?)p.Attribute("currentdbname") ?? (string?)p.Attribute("currentdb"),
+ LoginName = (string?)p.Attribute("loginname"),
+ HostName = (string?)p.Attribute("hostname"),
+ ClientApp = (string?)p.Attribute("clientapp"),
+ IsolationLevel = (string?)p.Attribute("isolationlevel"),
+ WaitResource = (string?)p.Attribute("waitresource"),
+ LockMode = (string?)p.Attribute("lockMode"),
+ InputBuffer = p.Element("inputbuf")?.Value.Trim(),
+ }).ToList() ?? [];
+
+ return new DeadlockEvent
+ {
+ Time = DateTimeOffset.Parse(timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
+ Processes = processes,
+ GraphXml = deadlock.ToString(SaveOptions.DisableFormatting),
+ };
+ }
+
+ private static XElement? TryParse(string text)
+ {
+ try
+ {
+ return XElement.Parse(text);
+ }
+ catch (System.Xml.XmlException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs
new file mode 100644
index 0000000..3bfedcb
--- /dev/null
+++ b/src/SQLInsight.Collectors/Databases/DatabaseCollector.cs
@@ -0,0 +1,223 @@
+using System.Data.Common;
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Instances;
+
+namespace SQLInsight.Collectors.Databases;
+
+///
+/// Settings, backup history, CHECKDB status, VLFs, Query Store state and files per database.
+///
+public sealed class DatabaseCollector : Collector
+{
+ // Backups of an earlier database with the same name (dropped and recreated) must not count, hence the
+ // create_date comparison.
+ private const string BackupJoin = """
+ LEFT JOIN (
+ SELECT database_name,
+ MAX(CASE WHEN type = 'D' THEN backup_finish_date END) AS last_full,
+ MAX(CASE WHEN type = 'I' THEN backup_finish_date END) AS last_diff,
+ MAX(CASE WHEN type = 'L' THEN backup_finish_date END) AS last_log,
+ MIN(backup_finish_date) AS first_backup
+ FROM msdb.dbo.backupset
+ GROUP BY database_name
+ ) b ON b.database_name = d.name
+ """;
+
+ public override string Name => "databases";
+ public override TimeSpan DefaultInterval => TimeSpan.FromMinutes(15);
+
+ protected override async Task CollectAsync(CollectorContext context, DatabaseSnapshot? previous)
+ {
+ var connection = context.Connection;
+ var ct = context.CancellationToken;
+ var version = context.Instance.Version;
+
+ List databases;
+ List<(int DatabaseId, DatabaseFileInfo File)> files;
+ if (version.Major >= 9)
+ {
+ databases = await Db.QueryAsync(connection, BuildSql2005(context.Instance, context.Capabilities), Map2005, ct).ConfigureAwait(false);
+ files = await Db.QueryAsync(connection, Files2005, MapFile, ct).ConfigureAwait(false);
+ }
+ else
+ {
+ databases = await Db.QueryAsync(connection, BuildSql2000(), Map2000, ct).ConfigureAwait(false);
+ files = await Db.QueryAsync(connection, Files2000, MapFile, ct).ConfigureAwait(false);
+ }
+
+ // msdb and DATABASEPROPERTYEX report server local time; everything downstream works in UTC.
+ var offsetMinutes = (await Db.QueryAsync(connection, "SELECT DATEDIFF(minute, GETUTCDATE(), GETDATE())", r => r.Int(0), ct).ConfigureAwait(false))[0];
+ DateTime? ToUtc(DateTime? local) => local is { Year: > 1900 } value ? DateTime.SpecifyKind(value.AddMinutes(-offsetMinutes), DateTimeKind.Utc) : local;
+
+ var vlfs = context.Capabilities.Has(Capability.LogInfoDmv)
+ ? (await Db.QueryAsync(connection, VlfSql, r => (Id: r.Int(0), Count: r.Int(1)), ct).ConfigureAwait(false)).ToDictionary(v => v.Id, v => v.Count)
+ : [];
+
+ var result = new List(databases.Count);
+ foreach (var database in databases)
+ {
+ var queryStore = context.Capabilities.Has(Capability.QueryStore) && ShouldReadQueryStore(database)
+ ? await ReadQueryStoreAsync(connection, database.Name, ct).ConfigureAwait(false)
+ : null;
+
+ result.Add(database with
+ {
+ CreateDate = ToUtc(database.CreateDate),
+ LastFullBackup = ToUtc(database.LastFullBackup),
+ LastDifferentialBackup = ToUtc(database.LastDifferentialBackup),
+ LastLogBackup = ToUtc(database.LastLogBackup),
+ LastGoodCheckDb = ToUtc(database.LastGoodCheckDb),
+ Files = files.Where(f => f.DatabaseId == database.DatabaseId).Select(f => f.File).ToList(),
+ VirtualLogFileCount = vlfs.TryGetValue(database.DatabaseId, out var count) ? count : null,
+ QueryStore = queryStore,
+ });
+ }
+
+ return new DatabaseSnapshot
+ {
+ CapturedAt = context.Now,
+ Databases = result,
+ TempdbFiles = files.Where(f => f.DatabaseId == 2).Select(f => f.File).ToList(),
+ };
+ }
+
+ internal static string BuildSql2005(InstanceInfo instance, InstanceCapabilities capabilities)
+ {
+ var version = instance.Version;
+ var checkDb = capabilities.Has(Capability.LastGoodCheckDbTime)
+ ? "CAST(DATABASEPROPERTYEX(d.name, 'LastGoodCheckDbTime') AS datetime)"
+ : "CAST(NULL AS datetime)";
+ var inAg = version.Major >= 11 ? "CASE WHEN d.replica_id IS NOT NULL THEN 1 ELSE 0 END" : "0";
+ var agSecondary = version.Major >= 11
+ ? """
+ OR EXISTS (SELECT 1 FROM sys.dm_hadr_database_replica_states rs
+ JOIN sys.dm_hadr_availability_replica_states ars ON ars.replica_id = rs.replica_id
+ WHERE rs.database_id = d.database_id AND rs.is_local = 1 AND ars.role = 2)
+ """
+ : string.Empty;
+
+ return $"""
+ SELECT d.name, d.database_id, d.state_desc, d.recovery_model_desc, d.compatibility_level, d.page_verify_option_desc,
+ d.is_auto_close_on, d.is_auto_shrink_on, d.is_read_only, d.is_trustworthy_on, d.create_date,
+ CASE WHEN EXISTS (SELECT 1 FROM sys.database_mirroring m WHERE m.database_id = d.database_id AND m.mirroring_role = 2)
+ {agSecondary}
+ THEN 1 ELSE 0 END,
+ {inAg},
+ CASE WHEN b.last_full >= d.create_date THEN b.last_full END,
+ CASE WHEN b.last_diff >= d.create_date THEN b.last_diff END,
+ CASE WHEN b.last_log >= d.create_date THEN b.last_log END,
+ {checkDb}
+ FROM sys.databases d
+ {BackupJoin}
+ WHERE d.name <> N'tempdb'
+ """;
+ }
+
+ internal static string BuildSql2000() => $"""
+ SELECT d.name, d.dbid,
+ CAST(DATABASEPROPERTYEX(d.name, 'Status') AS nvarchar(60)),
+ CAST(DATABASEPROPERTYEX(d.name, 'Recovery') AS nvarchar(60)),
+ d.cmptlevel,
+ CASE WHEN DATABASEPROPERTYEX(d.name, 'IsTornPageDetectionEnabled') = 1 THEN N'TORN_PAGE_DETECTION' ELSE N'NONE' END,
+ CAST(DATABASEPROPERTYEX(d.name, 'IsAutoClose') AS int),
+ CAST(DATABASEPROPERTYEX(d.name, 'IsAutoShrink') AS int),
+ CASE WHEN DATABASEPROPERTYEX(d.name, 'Updateability') = 'READ_ONLY' THEN 1 ELSE 0 END,
+ 0,
+ d.crdate,
+ 0,
+ 0,
+ CASE WHEN b.last_full >= d.crdate THEN b.last_full END,
+ CASE WHEN b.last_diff >= d.crdate THEN b.last_diff END,
+ CASE WHEN b.last_log >= d.crdate THEN b.last_log END,
+ CAST(NULL AS datetime)
+ FROM master.dbo.sysdatabases d
+ {BackupJoin}
+ WHERE d.name <> N'tempdb'
+ """;
+
+ internal const string Files2005 = """
+ SELECT database_id, name, type_desc, physical_name,
+ CAST(size AS bigint) / 128,
+ CASE WHEN is_percent_growth = 1 THEN growth ELSE CAST(growth AS bigint) / 128 END,
+ is_percent_growth,
+ CASE WHEN max_size = -1 THEN -1 ELSE CAST(max_size AS bigint) / 128 END
+ FROM sys.master_files
+ WHERE type_desc IN (N'ROWS', N'LOG')
+ """;
+
+ // sysaltfiles.status 0x100000 = growth in percent; groupid 0 = log file.
+ internal const string Files2000 = """
+ SELECT dbid, name, CASE WHEN groupid = 0 THEN N'LOG' ELSE N'ROWS' END, filename,
+ CAST(size AS bigint) / 128,
+ CASE WHEN status & 0x100000 <> 0 THEN growth ELSE CAST(growth AS bigint) / 128 END,
+ CASE WHEN status & 0x100000 <> 0 THEN 1 ELSE 0 END,
+ CASE WHEN maxsize = -1 THEN -1 ELSE CAST(maxsize AS bigint) / 128 END
+ FROM master.dbo.sysaltfiles
+ """;
+
+ internal const string VlfSql = """
+ SELECT d.database_id, COUNT(*)
+ FROM sys.databases d
+ CROSS APPLY sys.dm_db_log_info(d.database_id) li
+ WHERE d.state = 0
+ GROUP BY d.database_id
+ """;
+
+ private static DatabaseInfo Map2005(Row r) => new()
+ {
+ Name = r.String(0),
+ DatabaseId = r.Int(1),
+ State = r.String(2),
+ RecoveryModel = r.String(3),
+ CompatibilityLevel = r.Int(4),
+ PageVerify = r.NullableString(5),
+ IsAutoClose = r.Bool(6),
+ IsAutoShrink = r.Bool(7),
+ IsReadOnly = r.Bool(8),
+ IsTrustworthy = r.Bool(9),
+ CreateDate = r.NullableDateTime(10),
+ IsReplicaOrMirror = r.Bool(11),
+ IsInAvailabilityGroup = r.Bool(12),
+ LastFullBackup = r.NullableDateTime(13),
+ LastDifferentialBackup = r.NullableDateTime(14),
+ LastLogBackup = r.NullableDateTime(15),
+ LastGoodCheckDb = r.NullableDateTime(16),
+ };
+
+ private static DatabaseInfo Map2000(Row r) => Map2005(r) with { State = r.NullableString(2) ?? "UNKNOWN", RecoveryModel = r.NullableString(3) ?? "UNKNOWN" };
+
+ private static (int, DatabaseFileInfo) MapFile(Row r) => (r.Int(0), new DatabaseFileInfo
+ {
+ LogicalName = r.String(1),
+ Type = r.String(2),
+ PhysicalName = r.String(3),
+ SizeMb = r.Long(4),
+ Growth = r.Long(5),
+ IsPercentGrowth = r.Bool(6),
+ MaxSizeMb = r.Long(7),
+ });
+
+ private static bool ShouldReadQueryStore(DatabaseInfo database) =>
+ database.State == "ONLINE" && !database.IsSystemDatabase && !database.IsReplicaOrMirror;
+
+ private static async Task ReadQueryStoreAsync(DbConnection connection, string database, CancellationToken ct)
+ {
+ var sql = $"""
+ SELECT desired_state_desc, actual_state_desc, readonly_reason, current_storage_size_mb, max_storage_size_mb
+ FROM {Db.Identifier(database)}.sys.database_query_store_options
+ """;
+ try
+ {
+ return await Db.QuerySingleAsync(
+ connection,
+ sql,
+ r => new QueryStoreState(r.String(0), r.String(1), r.Int(2), r.Long(3), r.Long(4)),
+ ct).ConfigureAwait(false);
+ }
+ catch (DbException)
+ {
+ // No access to this database (e.g. the agent has no user in it on pre-2014 instances). Leave it unknown.
+ return null;
+ }
+ }
+}
diff --git a/src/SQLInsight.Collectors/Db.cs b/src/SQLInsight.Collectors/Db.cs
new file mode 100644
index 0000000..e558e6f
--- /dev/null
+++ b/src/SQLInsight.Collectors/Db.cs
@@ -0,0 +1,89 @@
+using System.Data.Common;
+using System.Globalization;
+
+namespace SQLInsight.Collectors;
+
+///
+/// Minimal, provider-neutral query helpers. Works the same over SqlClient and ODBC, so collectors never use
+/// named parameters (ODBC only knows positional ?); the few literals needed are escaped with .
+///
+internal static class Db
+{
+ public const int CommandTimeoutSeconds = 30;
+
+ public static async Task> QueryAsync(DbConnection connection, string sql, Func map, CancellationToken cancellationToken)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.CommandTimeout = CommandTimeoutSeconds;
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ var rows = new List();
+ var row = new Row(reader);
+ while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
+ {
+ rows.Add(map(row));
+ }
+
+ return rows;
+ }
+
+ public static async Task QuerySingleAsync(DbConnection connection, string sql, Func map, CancellationToken cancellationToken)
+ where T : class
+ {
+ var rows = await QueryAsync(connection, sql, map, cancellationToken).ConfigureAwait(false);
+ return rows.Count > 0 ? rows[0] : null;
+ }
+
+ /// An N'…' string literal with quotes escaped.
+ public static string Literal(string value) => "N'" + value.Replace("'", "''", StringComparison.Ordinal) + "'";
+
+ /// A [bracketed] identifier with brackets escaped.
+ public static string Identifier(string name) => "[" + name.Replace("]", "]]", StringComparison.Ordinal) + "]";
+
+ ///
+ /// The SQL expression for when the instance started. Cumulative DMVs reset at that moment.
+ ///
+ public static string StartTimeExpression(Core.Instances.SqlServerVersion version) => version.Major switch
+ {
+ >= 10 => "(SELECT sqlserver_start_time FROM sys.dm_os_sys_info)",
+ 9 => "(SELECT create_date FROM sys.databases WHERE database_id = 2)",
+ _ => "(SELECT login_time FROM master.dbo.sysprocesses WHERE spid = 1)",
+ };
+
+ public static async Task StartTimeAsync(DbConnection connection, Core.Instances.SqlServerVersion version, CancellationToken cancellationToken)
+ {
+ var rows = await QueryAsync(connection, "SELECT " + StartTimeExpression(version), r => r.DateTime(0), cancellationToken).ConfigureAwait(false);
+ return rows[0];
+ }
+}
+
+///
+/// Type-tolerant access to a data reader row. DMV column types differ between versions (int vs bigint,
+/// nchar vs nvarchar), so values are converted rather than cast.
+///
+internal sealed class Row(DbDataReader reader)
+{
+ public bool IsNull(int ordinal) => reader.IsDBNull(ordinal);
+
+ public string String(int ordinal) => Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture)!.TrimEnd();
+
+ public string? NullableString(int ordinal) => reader.IsDBNull(ordinal) ? null : String(ordinal);
+
+ public long Long(int ordinal) => Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
+
+ public long? NullableLong(int ordinal) => reader.IsDBNull(ordinal) ? null : Long(ordinal);
+
+ public int Int(int ordinal) => Convert.ToInt32(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
+
+ public int? NullableInt(int ordinal) => reader.IsDBNull(ordinal) ? null : Int(ordinal);
+
+ public double Double(int ordinal) => Convert.ToDouble(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
+
+ public bool Bool(int ordinal) => !reader.IsDBNull(ordinal) && Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) != 0;
+
+ public DateTime DateTime(int ordinal) => Convert.ToDateTime(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
+
+ public DateTime? NullableDateTime(int ordinal) => reader.IsDBNull(ordinal) ? null : DateTime(ordinal);
+
+ public int FieldCount => reader.FieldCount;
+}
diff --git a/src/SQLInsight.Collectors/ICollector.cs b/src/SQLInsight.Collectors/ICollector.cs
new file mode 100644
index 0000000..001b2e9
--- /dev/null
+++ b/src/SQLInsight.Collectors/ICollector.cs
@@ -0,0 +1,49 @@
+using System.Data.Common;
+using SQLInsight.Core.Instances;
+
+namespace SQLInsight.Collectors;
+
+///
+/// Reads one kind of data from a monitored instance. Collectors are stateless; anything they need from the
+/// previous run (to compute deltas or continue reading) is passed in .
+///
+public interface ICollector
+{
+ string Name { get; }
+
+ /// The type of the collected data, as rules look it up in CollectedData.
+ Type DataType { get; }
+
+ TimeSpan DefaultInterval { get; }
+
+ /// What the instance must support. If anything is missing, the collector does not run.
+ IReadOnlyCollection RequiredCapabilities { get; }
+
+ Task