@cryptotaxi247 / netdata-1 / commits / 63524f5d2

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