Skip to content

Commit 27dc20a

Browse files
Merge pull request #464 from erikdarlingdata/feature/finops-monitoring
Add FinOps monitoring collectors and reporting views
2 parents f71bd58 + 4a5b8a9 commit 27dc20a

15 files changed

Lines changed: 1827 additions & 4 deletions

Lite/Database/DuckDbInitializer.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public void Dispose()
8686
/// <summary>
8787
/// Current schema version. Increment this when schema changes require table rebuilds.
8888
/// </summary>
89-
internal const int CurrentSchemaVersion = 15;
89+
internal const int CurrentSchemaVersion = 16;
9090

9191
private readonly string _archivePath;
9292

@@ -497,6 +497,14 @@ Must drop/recreate because DuckDB appender writes by position. */
497497
_logger?.LogInformation("Running migration to v15: rebuilding file_io_stats for queued I/O columns");
498498
await ExecuteNonQueryAsync(connection, "DROP TABLE IF EXISTS file_io_stats");
499499
}
500+
501+
if (fromVersion < 16)
502+
{
503+
/* v16: Added database_size_stats and server_properties tables for FinOps monitoring.
504+
New tables only — no existing table changes needed. Tables created by
505+
GetAllTableStatements() during initialization. */
506+
_logger?.LogInformation("Running migration to v16: adding FinOps tables (database_size_stats, server_properties)");
507+
}
500508
}
501509

502510
/// <summary>

Lite/Database/Schema.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,55 @@ percent_of_average DECIMAL(10,1)
591591
public const string CreateRunningJobsIndex = @"
592592
CREATE INDEX IF NOT EXISTS idx_running_jobs_time ON running_jobs(server_id, collection_time)";
593593

594+
public const string CreateDatabaseSizeStatsTable = @"
595+
CREATE TABLE IF NOT EXISTS database_size_stats (
596+
collection_id BIGINT PRIMARY KEY,
597+
collection_time TIMESTAMP NOT NULL,
598+
server_id INTEGER NOT NULL,
599+
server_name VARCHAR NOT NULL,
600+
database_name VARCHAR NOT NULL,
601+
database_id INTEGER NOT NULL,
602+
file_id INTEGER NOT NULL,
603+
file_type_desc VARCHAR NOT NULL,
604+
file_name VARCHAR NOT NULL,
605+
physical_name VARCHAR NOT NULL,
606+
total_size_mb DECIMAL(19,2) NOT NULL,
607+
used_size_mb DECIMAL(19,2),
608+
auto_growth_mb DECIMAL(19,2),
609+
max_size_mb DECIMAL(19,2),
610+
recovery_model_desc VARCHAR,
611+
compatibility_level INTEGER,
612+
state_desc VARCHAR
613+
)";
614+
615+
public const string CreateDatabaseSizeStatsIndex = @"
616+
CREATE INDEX IF NOT EXISTS idx_database_size_stats_time ON database_size_stats(server_id, collection_time)";
617+
618+
public const string CreateServerPropertiesTable = @"
619+
CREATE TABLE IF NOT EXISTS server_properties (
620+
collection_id BIGINT PRIMARY KEY,
621+
collection_time TIMESTAMP NOT NULL,
622+
server_id INTEGER NOT NULL,
623+
server_name VARCHAR NOT NULL,
624+
edition VARCHAR NOT NULL,
625+
product_version VARCHAR NOT NULL,
626+
product_level VARCHAR NOT NULL,
627+
product_update_level VARCHAR,
628+
engine_edition INTEGER NOT NULL,
629+
cpu_count INTEGER NOT NULL,
630+
hyperthread_ratio INTEGER NOT NULL,
631+
physical_memory_mb BIGINT NOT NULL,
632+
socket_count INTEGER,
633+
cores_per_socket INTEGER,
634+
is_hadr_enabled BOOLEAN,
635+
is_clustered BOOLEAN,
636+
enterprise_features VARCHAR,
637+
service_objective VARCHAR
638+
)";
639+
640+
public const string CreateServerPropertiesIndex = @"
641+
CREATE INDEX IF NOT EXISTS idx_server_properties_time ON server_properties(server_id, collection_time)";
642+
594643
public const string CreateAlertLogTable = @"
595644
CREATE TABLE IF NOT EXISTS config_alert_log (
596645
alert_time TIMESTAMP NOT NULL,
@@ -633,6 +682,8 @@ public static IEnumerable<string> GetAllTableStatements()
633682
yield return CreateDatabaseScopedConfigTable;
634683
yield return CreateTraceFlagsTable;
635684
yield return CreateRunningJobsTable;
685+
yield return CreateDatabaseSizeStatsTable;
686+
yield return CreateServerPropertiesTable;
636687
yield return CreateAlertLogTable;
637688
}
638689

@@ -660,5 +711,7 @@ public static IEnumerable<string> GetAllIndexStatements()
660711
yield return CreateDatabaseScopedConfigIndex;
661712
yield return CreateTraceFlagsIndex;
662713
yield return CreateRunningJobsIndex;
714+
yield return CreateDatabaseSizeStatsIndex;
715+
yield return CreateServerPropertiesIndex;
663716
}
664717
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
* Copyright (c) 2026 Erik Darling, Darling Data LLC
3+
*
4+
* This file is part of the SQL Server Performance Monitor Lite.
5+
*
6+
* Licensed under the MIT License. See LICENSE file in the project root for full license information.
7+
*/
8+
9+
using System;
10+
using System.Collections.Generic;
11+
using System.Diagnostics;
12+
using System.Threading;
13+
using System.Threading.Tasks;
14+
using DuckDB.NET.Data;
15+
using Microsoft.Data.SqlClient;
16+
using Microsoft.Extensions.Logging;
17+
using PerformanceMonitorLite.Models;
18+
19+
namespace PerformanceMonitorLite.Services;
20+
21+
public partial class RemoteCollectorService
22+
{
23+
/// <summary>
24+
/// Collects per-file database sizes for growth trending and capacity planning.
25+
/// On-prem: queries sys.master_files + sys.databases for all online databases.
26+
/// Azure SQL DB: queries sys.database_files for the single database.
27+
/// </summary>
28+
private async Task<int> CollectDatabaseSizeStatsAsync(ServerConnection server, CancellationToken cancellationToken)
29+
{
30+
var serverStatus = _serverManager.GetConnectionStatus(server.Id);
31+
bool isAzureSqlDb = serverStatus?.SqlEngineEdition == 5;
32+
33+
const string onPremQuery = @"
34+
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
35+
36+
SELECT
37+
database_name = d.name,
38+
database_id = d.database_id,
39+
file_id = mf.file_id,
40+
file_type_desc = mf.type_desc,
41+
file_name = mf.name,
42+
physical_name = mf.physical_name,
43+
total_size_mb =
44+
CONVERT(decimal(19,2), mf.size * 8.0 / 1024.0),
45+
used_size_mb =
46+
CONVERT(decimal(19,2), NULL),
47+
auto_growth_mb =
48+
CASE
49+
WHEN mf.is_percent_growth = 1
50+
THEN CONVERT(decimal(19,2), NULL)
51+
ELSE CONVERT(decimal(19,2), mf.growth * 8.0 / 1024.0)
52+
END,
53+
max_size_mb =
54+
CASE
55+
WHEN mf.max_size = -1
56+
THEN CONVERT(decimal(19,2), -1)
57+
WHEN mf.max_size = 268435456
58+
THEN CONVERT(decimal(19,2), 2097152)
59+
ELSE CONVERT(decimal(19,2), mf.max_size * 8.0 / 1024.0)
60+
END,
61+
recovery_model_desc =
62+
d.recovery_model_desc,
63+
compatibility_level =
64+
d.compatibility_level,
65+
state_desc =
66+
d.state_desc
67+
FROM sys.master_files AS mf
68+
JOIN sys.databases AS d
69+
ON d.database_id = mf.database_id
70+
WHERE d.state_desc = N'ONLINE'
71+
ORDER BY
72+
d.name,
73+
mf.file_id
74+
OPTION(RECOMPILE);";
75+
76+
const string azureSqlDbQuery = @"
77+
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
78+
79+
SELECT
80+
database_name = DB_NAME(),
81+
database_id = DB_ID(),
82+
file_id = df.file_id,
83+
file_type_desc = df.type_desc,
84+
file_name = df.name,
85+
physical_name = df.physical_name,
86+
total_size_mb =
87+
CONVERT(decimal(19,2), df.size * 8.0 / 1024.0),
88+
used_size_mb =
89+
CONVERT(decimal(19,2), FILEPROPERTY(df.name, N'SpaceUsed') * 8.0 / 1024.0),
90+
auto_growth_mb =
91+
CASE
92+
WHEN df.is_percent_growth = 1
93+
THEN CONVERT(decimal(19,2), NULL)
94+
ELSE CONVERT(decimal(19,2), df.growth * 8.0 / 1024.0)
95+
END,
96+
max_size_mb =
97+
CASE
98+
WHEN df.max_size = -1
99+
THEN CONVERT(decimal(19,2), -1)
100+
WHEN df.max_size = 268435456
101+
THEN CONVERT(decimal(19,2), 2097152)
102+
ELSE CONVERT(decimal(19,2), df.max_size * 8.0 / 1024.0)
103+
END,
104+
recovery_model_desc =
105+
CONVERT(nvarchar(12), DATABASEPROPERTYEX(DB_NAME(), N'Recovery')),
106+
compatibility_level =
107+
CONVERT(int, NULL),
108+
state_desc =
109+
N'ONLINE'
110+
FROM sys.database_files AS df
111+
ORDER BY
112+
df.file_id
113+
OPTION(RECOMPILE);";
114+
115+
string query = isAzureSqlDb ? azureSqlDbQuery : onPremQuery;
116+
117+
var serverId = GetServerId(server);
118+
var collectionTime = DateTime.UtcNow;
119+
var rowsCollected = 0;
120+
_lastSqlMs = 0;
121+
_lastDuckDbMs = 0;
122+
123+
var rows = new List<(string DatabaseName, int DatabaseId, int FileId, string FileTypeDesc,
124+
string FileName, string PhysicalName, decimal TotalSizeMb, decimal? UsedSizeMb,
125+
decimal? AutoGrowthMb, decimal? MaxSizeMb, string? RecoveryModel,
126+
int? CompatibilityLevel, string? StateDesc)>();
127+
128+
var sqlSw = Stopwatch.StartNew();
129+
using var sqlConnection = await CreateConnectionAsync(server, cancellationToken);
130+
using var command = new SqlCommand(query, sqlConnection);
131+
command.CommandTimeout = CommandTimeoutSeconds;
132+
133+
using var reader = await command.ExecuteReaderAsync(cancellationToken);
134+
while (await reader.ReadAsync(cancellationToken))
135+
{
136+
rows.Add((
137+
reader.GetString(0),
138+
reader.GetInt32(1),
139+
reader.GetInt32(2),
140+
reader.GetString(3),
141+
reader.GetString(4),
142+
reader.GetString(5),
143+
reader.GetDecimal(6),
144+
reader.IsDBNull(7) ? null : reader.GetDecimal(7),
145+
reader.IsDBNull(8) ? null : reader.GetDecimal(8),
146+
reader.IsDBNull(9) ? null : reader.GetDecimal(9),
147+
reader.IsDBNull(10) ? null : reader.GetString(10),
148+
reader.IsDBNull(11) ? null : reader.GetInt32(11),
149+
reader.IsDBNull(12) ? null : reader.GetString(12)));
150+
}
151+
sqlSw.Stop();
152+
153+
var duckSw = Stopwatch.StartNew();
154+
155+
using (var duckConnection = _duckDb.CreateConnection())
156+
{
157+
await duckConnection.OpenAsync(cancellationToken);
158+
159+
using (var appender = duckConnection.CreateAppender("database_size_stats"))
160+
{
161+
foreach (var r in rows)
162+
{
163+
var row = appender.CreateRow();
164+
row.AppendValue(GenerateCollectionId())
165+
.AppendValue(collectionTime)
166+
.AppendValue(serverId)
167+
.AppendValue(server.ServerName)
168+
.AppendValue(r.DatabaseName)
169+
.AppendValue(r.DatabaseId)
170+
.AppendValue(r.FileId)
171+
.AppendValue(r.FileTypeDesc)
172+
.AppendValue(r.FileName)
173+
.AppendValue(r.PhysicalName)
174+
.AppendValue(r.TotalSizeMb)
175+
.AppendValue(r.UsedSizeMb)
176+
.AppendValue(r.AutoGrowthMb)
177+
.AppendValue(r.MaxSizeMb)
178+
.AppendValue(r.RecoveryModel)
179+
.AppendValue(r.CompatibilityLevel)
180+
.AppendValue(r.StateDesc)
181+
.EndRow();
182+
rowsCollected++;
183+
}
184+
}
185+
}
186+
187+
duckSw.Stop();
188+
_lastSqlMs = sqlSw.ElapsedMilliseconds;
189+
_lastDuckDbMs = duckSw.ElapsedMilliseconds;
190+
191+
_logger?.LogDebug("Collected {RowCount} database size rows for server '{Server}'", rowsCollected, server.DisplayName);
192+
return rowsCollected;
193+
}
194+
}

0 commit comments

Comments
 (0)