feat(go.d/mssql): add transaction log monitoring (#22319)
Ilya Mashchenko committed
Apr 30, 2026 at 11:19 UTC
63524f5d22f3c4c205d0b69a856e18e3d6e09ace
8 files changed
+321
-16
src/go/plugin/go.d/collector/mssql/charts.go
+65
@@ -48,6 +48,9 @@ const (
48
prioDatabaseLogFlushes
49
prioDatabaseLogFlushed
50
prioDatabaseLogGrowths
51
+ prioDatabaseLogFileSize
52
+ prioDatabaseLogPercentUsed
53
+ prioDatabaseLogTruncationsShrinks
54
prioDatabaseIOStall
55
prioDatabaseDeadlocks
56
prioDatabaseLockWaits
@@ -541,6 +544,44 @@ var (
544
{ID: "database_%s_log_growths", Name: "growths", Algo: collectorapi.Incremental},
545
},
546
}
547
+ databaseLogFileSizeChartTmpl = collectorapi.Chart{
548
+ ID: "database_%s_log_file_size",
549
+ Title: "Transaction log file size",
550
+ Units: "bytes",
551
+ Fam: "db log",
552
+ Ctx: "mssql.database_log_file_size",
553
+ Type: collectorapi.Stacked,
554
+ Priority: prioDatabaseLogFileSize,
555
+ Dims: collectorapi.Dims{
556
+ {ID: "database_%s_log_size_used", Name: "used"},
557
+ {ID: "database_%s_log_size_free", Name: "free"},
558
+ },
559
+ }
560
+ databaseLogPercentUsedChartTmpl = collectorapi.Chart{
561
+ ID: "database_%s_log_percent_used",
562
+ Title: "Transaction log space utilization",
563
+ Units: "percentage",
564
+ Fam: "db log",
565
+ Ctx: "mssql.database_log_percent_used",
566
+ Type: collectorapi.Line,
567
+ Priority: prioDatabaseLogPercentUsed,
568
+ Dims: collectorapi.Dims{
569
+ {ID: "database_%s_log_percent_used", Name: "used", Div: 100},
570
+ },
571
+ }
572
+ databaseLogTruncationsShrinksChartTmpl = collectorapi.Chart{
573
+ ID: "database_%s_log_truncations_shrinks",
574
+ Title: "Transaction log truncations and shrinks",
575
+ Units: "events/s",
576
+ Fam: "db log",
577
+ Ctx: "mssql.database_log_truncations_shrinks",
578
+ Type: collectorapi.Line,
579
+ Priority: prioDatabaseLogTruncationsShrinks,
580
+ Dims: collectorapi.Dims{
581
+ {ID: "database_%s_log_truncations", Name: "truncations", Algo: collectorapi.Incremental},
582
+ {ID: "database_%s_log_shrinks", Name: "shrinks", Algo: collectorapi.Incremental},
583
+ },
584
+ }
585
databaseIOStallChartTmpl = collectorapi.Chart{
586
ID: "database_%s_io_stall",
587
Title: "Database I/O stall time",
@@ -789,6 +830,30 @@ func (c *Collector) addDatabaseCharts(dbName string) {
830
}
831
}
832
833
+func (c *Collector) addDatabaseLogCharts(dbName string) {
834
+ charts := &collectorapi.Charts{
835
+ databaseLogFileSizeChartTmpl.Copy(),
836
+ databaseLogPercentUsedChartTmpl.Copy(),
837
+ databaseLogTruncationsShrinksChartTmpl.Copy(),
838
+ }
839
+
840
+ dbID := cleanDatabaseName(dbName)
841
+
842
+ for _, chart := range *charts {
843
+ chart.ID = fmt.Sprintf(chart.ID, dbID)
844
+ chart.Labels = []collectorapi.Label{
845
+ {Key: "database", Value: dbName},
846
+ }
847
+ for _, dim := range chart.Dims {
848
+ dim.ID = fmt.Sprintf(dim.ID, dbID)
849
+ }
850
+ }
851
+
852
+ if err := c.Charts().Add(*charts...); err != nil {
853
+ c.Warning(err)
854
+ }
855
+}
856
+
857
func (c *Collector) addWaitTypeCharts(waitType string, waitCategory string) {
858
charts := &collectorapi.Charts{
859
waitTotalTimeChartTmpl.Copy(),
src/go/plugin/go.d/collector/mssql/collect.go
+112
@@ -16,6 +16,11 @@ import (
16
// noLatencySentinel is the value SQL Server returns when no latency data is available
17
const noLatencySentinel = 999999
18
19
+const (
20
+ maxKBToBytes = int64(1<<63-1) / 1024
21
+ maxKBToCentiPercent = int64(1<<63-1) / 10000
22
+)
23
+
24
func (c *Collector) collect() (map[string]int64, error) {
25
if c.db == nil {
26
db, err := c.openConnection()
@@ -358,6 +363,9 @@ func (c *Collector) collectDatabaseMetrics(mx map[string]int64) error {
363
if err := c.collectLogGrowths(mx); err != nil {
364
return err
365
}
366
+ if err := c.collectDatabaseLogCounters(mx); err != nil {
367
+ return err
368
+ }
369
370
return nil
371
}
@@ -407,6 +415,110 @@ func (c *Collector) collectDatabaseCounters(mx map[string]int64) error {
415
return rows.Err()
416
}
417
418
+type databaseLogCounters struct {
419
+ sizeKB int64
420
+ usedKB int64
421
+ truncations int64
422
+ shrinks int64
423
+ hasSize bool
424
+ hasUsed bool
425
+ hasTruncations bool
426
+ hasShrinks bool
427
+ hasKnownCounter bool
428
+}
429
+
430
+func (c *Collector) collectDatabaseLogCounters(mx map[string]int64) error {
431
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
432
+ defer cancel()
433
+
434
+ rows, err := c.db.QueryContext(ctx, queryDatabaseLogCounters)
435
+ if err != nil {
436
+ return fmt.Errorf("database log counters query failed: %v", err)
437
+ }
438
+ defer rows.Close()
439
+
440
+ counters := make(map[string]*databaseLogCounters)
441
+
442
+ for rows.Next() {
443
+ var dbName, counterName string
444
+ var value int64
445
+ if err := rows.Scan(&dbName, &counterName, &value); err != nil {
446
+ continue
447
+ }
448
+ if value < 0 {
449
+ continue
450
+ }
451
+
452
+ dbName = strings.TrimSpace(dbName)
453
+ counterName = strings.TrimSpace(counterName)
454
+ if dbName == "" {
455
+ continue
456
+ }
457
+
458
+ if !c.seenDatabasesWithLog[dbName] {
459
+ c.seenDatabasesWithLog[dbName] = true
460
+ c.addDatabaseLogCharts(dbName)
461
+ }
462
+
463
+ if counters[dbName] == nil {
464
+ counters[dbName] = &databaseLogCounters{}
465
+ }
466
+
467
+ switch counterName {
468
+ case "Log File(s) Size (KB)":
469
+ counters[dbName].sizeKB = value
470
+ counters[dbName].hasSize = true
471
+ counters[dbName].hasKnownCounter = true
472
+ case "Log File(s) Used Size (KB)":
473
+ counters[dbName].usedKB = value
474
+ counters[dbName].hasUsed = true
475
+ counters[dbName].hasKnownCounter = true
476
+ case "Log Truncations":
477
+ counters[dbName].truncations = value
478
+ counters[dbName].hasTruncations = true
479
+ counters[dbName].hasKnownCounter = true
480
+ case "Log Shrinks":
481
+ counters[dbName].shrinks = value
482
+ counters[dbName].hasShrinks = true
483
+ counters[dbName].hasKnownCounter = true
484
+ }
485
+ }
486
+
487
+ if err := rows.Err(); err != nil {
488
+ return err
489
+ }
490
+
491
+ for dbName, values := range counters {
492
+ if values == nil || !values.hasKnownCounter {
493
+ continue
494
+ }
495
+
496
+ dbID := cleanDatabaseName(dbName)
497
+
498
+ if values.hasSize && values.hasUsed && values.sizeKB > 0 &&
499
+ values.sizeKB <= maxKBToBytes && values.usedKB <= maxKBToBytes {
500
+ usedBytes := values.usedKB * 1024
501
+ freeKB := max(values.sizeKB-values.usedKB, 0)
502
+
503
+ mx[fmt.Sprintf("database_%s_log_size_used", dbID)] = usedBytes
504
+ mx[fmt.Sprintf("database_%s_log_size_free", dbID)] = freeKB * 1024
505
+
506
+ if values.usedKB <= maxKBToCentiPercent {
507
+ mx[fmt.Sprintf("database_%s_log_percent_used", dbID)] = values.usedKB * 10000 / values.sizeKB
508
+ }
509
+ }
510
+
511
+ if values.hasTruncations {
512
+ mx[fmt.Sprintf("database_%s_log_truncations", dbID)] = values.truncations
513
+ }
514
+ if values.hasShrinks {
515
+ mx[fmt.Sprintf("database_%s_log_shrinks", dbID)] = values.shrinks
516
+ }
517
+ }
518
+
519
+ return nil
520
+}
521
+
522
func (c *Collector) collectLockStatsByResourceType(mx map[string]int64) error {
523
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
524
defer cancel()
src/go/plugin/go.d/collector/mssql/collector.go
+14
-12
@@ -50,12 +50,13 @@ func New() *Collector {
50
51
charts: instanceCharts.Copy(),
52
53
- seenDatabases: make(map[string]bool),
54
- seenWaitTypes: make(map[string]bool),
55
- seenLockTypes: make(map[string]bool),
56
- seenLockStatsTypes: make(map[string]bool),
57
- seenJobs: make(map[string]bool),
58
- seenReplications: make(map[string]bool),
53
+ seenDatabases: make(map[string]bool),
54
+ seenDatabasesWithLog: make(map[string]bool),
55
+ seenWaitTypes: make(map[string]bool),
56
+ seenLockTypes: make(map[string]bool),
57
+ seenLockStatsTypes: make(map[string]bool),
58
+ seenJobs: make(map[string]bool),
59
+ seenReplications: make(map[string]bool),
60
61
seenAGs: make(map[string]bool),
62
seenAGReplicas: make(map[string]bool),
@@ -155,12 +156,13 @@ type Collector struct {
156
157
version string
158
158
- seenDatabases map[string]bool
159
- seenWaitTypes map[string]bool
160
- seenLockTypes map[string]bool
161
- seenLockStatsTypes map[string]bool
162
- seenJobs map[string]bool
163
- seenReplications map[string]bool
159
+ seenDatabases map[string]bool
160
+ seenDatabasesWithLog map[string]bool
161
+ seenWaitTypes map[string]bool
162
+ seenLockTypes map[string]bool
163
+ seenLockStatsTypes map[string]bool
164
+ seenJobs map[string]bool
165
+ seenReplications map[string]bool
166
167
hadrEnabled bool // true if Always On AG is enabled on this instance
168
hadrChecked bool // true after the HADR check has been performed
src/go/plugin/go.d/collector/mssql/integrations/microsoft_sql_server.md
+7
-3
@@ -425,8 +425,11 @@ jobs:
425
426
## Alerts
427
428
-There are no alerts configured by default for this integration.
428
+The following alerts are available:
429
430
+| Alert name | On metric | Description |
431
+|:------------|:----------|:------------|
432
+| [ mssql_database_log_percent_used ](https://github.com/netdata/netdata/blob/master/src/health/health.d/mssql.conf) | mssql.database_log_percent_used | SQL Server transaction log percent used has been above 90% for the last 15 minutes |
433
434
## Metrics
435
@@ -492,6 +495,9 @@ Metrics:
495
| mssql.database_log_flushes | flushes | flushes/s | • | • |
496
| mssql.database_log_flushed | flushed | bytes/s | • | • |
497
| mssql.database_log_growths | growths | growths | • | • |
498
+| mssql.database_log_file_size | used, free | bytes | • | • |
499
+| mssql.database_log_percent_used | used | percentage | • | • |
500
+| mssql.database_log_truncations_shrinks | truncations, shrinks | events/s | • | • |
501
| mssql.database_io_stall | read, write | ms | • | • |
502
| mssql.database_data_file_size | size | bytes | • | • |
503
| mssql.database_backup_restore_throughput | throughput | bytes/s | • | • |
@@ -1059,5 +1065,3 @@ Ensure SQL Server is configured for mixed mode authentication if using SQL login
1065
The monitoring user needs VIEW SERVER STATE permission.
1066
Grant it with: `GRANT VIEW SERVER STATE TO netdata_user;`
1067
1062
-
1063
-
src/go/plugin/go.d/collector/mssql/metadata.yaml
+25
-1
@@ -400,7 +400,11 @@ modules:
400
description: |
401
The monitoring user needs VIEW SERVER STATE permission.
402
Grant it with: `GRANT VIEW SERVER STATE TO netdata_user;`
403
- alerts: []
403
+ alerts:
404
+ - name: mssql_database_log_percent_used
405
+ metric: mssql.database_log_percent_used
406
+ info: SQL Server transaction log percent used has been above 90% for the last 15 minutes
407
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/mssql.conf
408
functions:
409
description: |
410
This collector exposes real-time functions for interactive troubleshooting in the Live tab.
@@ -1155,6 +1159,26 @@ modules:
1159
chart_type: line
1160
dimensions:
1161
- name: growths
1162
+ - name: mssql.database_log_file_size
1163
+ description: Transaction Log File Size
1164
+ unit: bytes
1165
+ chart_type: stacked
1166
+ dimensions:
1167
+ - name: used
1168
+ - name: free
1169
+ - name: mssql.database_log_percent_used
1170
+ description: Transaction Log Space Utilization
1171
+ unit: percentage
1172
+ chart_type: line
1173
+ dimensions:
1174
+ - name: used
1175
+ - name: mssql.database_log_truncations_shrinks
1176
+ description: Transaction Log Truncations and Shrinks
1177
+ unit: events/s
1178
+ chart_type: line
1179
+ dimensions:
1180
+ - name: truncations
1181
+ - name: shrinks
1182
- name: mssql.database_io_stall
1183
description: I/O Stall Time
1184
unit: ms
src/go/plugin/go.d/collector/mssql/mssql_test.go
+62
@@ -152,6 +152,68 @@ func TestCollector_Collect(t *testing.T) {
152
notWantMetrics []string
153
checkCollector func(t *testing.T, c *Collector)
154
}{
155
+ "database log counters: complete unordered rows": {
156
+ prepareMock: func(mock sqlmock.Sqlmock) {
157
+ mock.ExpectQuery(queryDatabaseLogCounters).WillReturnRows(
158
+ sqlmock.NewRows([]string{"database_name", "counter_name", "cntr_value"}).
159
+ AddRow("AppDB", "Log Shrinks", int64(2)).
160
+ AddRow("AppDB", "Log File(s) Used Size (KB)", int64(256)).
161
+ AddRow("AppDB", "Log Truncations", int64(3)).
162
+ AddRow("AppDB", "Log File(s) Size (KB)", int64(1024)),
163
+ )
164
+ },
165
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
166
+ wantMetrics: map[string]int64{
167
+ "database_appdb_log_size_used": 256 * 1024,
168
+ "database_appdb_log_size_free": 768 * 1024,
169
+ "database_appdb_log_percent_used": 2500,
170
+ "database_appdb_log_truncations": 3,
171
+ "database_appdb_log_shrinks": 2,
172
+ },
173
+ checkCollector: func(t *testing.T, c *Collector) {
174
+ assert.True(t, c.seenDatabasesWithLog["AppDB"])
175
+ },
176
+ },
177
+ "database log counters: missing used skips size and percent": {
178
+ prepareMock: func(mock sqlmock.Sqlmock) {
179
+ mock.ExpectQuery(queryDatabaseLogCounters).WillReturnRows(
180
+ sqlmock.NewRows([]string{"database_name", "counter_name", "cntr_value"}).
181
+ AddRow("AppDB", "Log File(s) Size (KB)", int64(1024)).
182
+ AddRow("AppDB", "Log Truncations", int64(3)),
183
+ )
184
+ },
185
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
186
+ wantMetrics: map[string]int64{
187
+ "database_appdb_log_truncations": 3,
188
+ },
189
+ notWantMetrics: []string{
190
+ "database_appdb_log_size_used",
191
+ "database_appdb_log_size_free",
192
+ "database_appdb_log_percent_used",
193
+ },
194
+ },
195
+ "database log counters: used greater than size clamps free": {
196
+ prepareMock: func(mock sqlmock.Sqlmock) {
197
+ mock.ExpectQuery(queryDatabaseLogCounters).WillReturnRows(
198
+ sqlmock.NewRows([]string{"database_name", "counter_name", "cntr_value"}).
199
+ AddRow("AppDB", "Log File(s) Size (KB)", int64(100)).
200
+ AddRow("AppDB", "Log File(s) Used Size (KB)", int64(150)),
201
+ )
202
+ },
203
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
204
+ wantMetrics: map[string]int64{
205
+ "database_appdb_log_size_used": 150 * 1024,
206
+ "database_appdb_log_size_free": 0,
207
+ "database_appdb_log_percent_used": 15000,
208
+ },
209
+ },
210
+ "database log counters: query error": {
211
+ prepareMock: func(mock sqlmock.Sqlmock) {
212
+ mock.ExpectQuery(queryDatabaseLogCounters).WillReturnError(fmt.Errorf("access denied"))
213
+ },
214
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectDatabaseLogCounters(mx) },
215
+ wantErr: true,
216
+ },
217
"ag health: success": {
218
prepareMock: func(mock sqlmock.Sqlmock) {
219
mock.ExpectQuery(queryAGHealth).WillReturnRows(
src/go/plugin/go.d/collector/mssql/queries.go
+20
@@ -101,6 +101,26 @@ WHERE object_name LIKE '%Databases%'
101
);
102
`
103
104
+// queryDatabaseLogCounters gets per-database transaction log size and activity counters
105
+const queryDatabaseLogCounters = `
106
+SELECT
107
+ RTRIM(pc.instance_name) AS database_name,
108
+ RTRIM(pc.counter_name) AS counter_name,
109
+ pc.cntr_value
110
+FROM sys.dm_os_performance_counters AS pc
111
+INNER JOIN sys.databases AS d
112
+ ON d.database_id = DB_ID(RTRIM(pc.instance_name))
113
+WHERE pc.object_name LIKE '%Databases%'
114
+ AND pc.instance_name NOT IN ('_Total', 'mssqlsystemresource')
115
+ AND d.database_id > 4
116
+ AND pc.counter_name IN (
117
+ 'Log File(s) Size (KB)',
118
+ 'Log File(s) Used Size (KB)',
119
+ 'Log Truncations',
120
+ 'Log Shrinks'
121
+ );
122
+`
123
+
124
// queryDatabaseLocks gets per-database lock metrics
125
const queryDatabaseLocks = `
126
SELECT
src/health/health.d/mssql.conf
new
+16
@@ -0,0 +1,16 @@
1
+
2
+# database transaction log utilization
3
+
4
+ template: mssql_database_log_percent_used
5
+ on: mssql.database_log_percent_used
6
+ class: Utilization
7
+ type: Database
8
+component: Microsoft SQL Server
9
+ lookup: min -15m unaligned of used
10
+ units: %
11
+ every: 1m
12
+ warn: $this > 90
13
+ delay: down 5m multiplier 1.5 max 1h
14
+ summary: SQL Server database ${label:database} transaction log utilization
15
+ info: SQL Server transaction log percent used has been above 90% for the last 15 minutes
16
+ to: dba