go.d/mssql: add Always On Availability Group monitoring (#21927)
Co-authored-by: ilyam8 <ilya@netdata.cloud>
Costa Tsaousis committed
Mar 10, 2026 at 20:09 UTC
2ef679ee9758e37936039ae8a51ff73a4fffe0a5
6 files changed
+1567
-1
src/go/plugin/go.d/collector/mssql/charts.go
+437
@@ -69,6 +69,32 @@ const (
69
70
prioReplicationStatus
71
prioReplicationLatency
72
+
73
+ prioAGSyncHealth
74
+ prioAGRecoveryHealth
75
+
76
+ prioAGReplicaRole
77
+ prioAGReplicaConnectedState
78
+ prioAGReplicaSyncHealth
79
+
80
+ prioAGDBSyncState
81
+ prioAGDBLogSendQueue
82
+ prioAGDBLogSendRate
83
+ prioAGDBRedoQueue
84
+ prioAGDBRedoRate
85
+ prioAGDBFilestreamSendRate
86
+ prioAGDBSecondaryLag
87
+ prioAGDBSuspended
88
+ prioAGDBFailoverReadiness
89
+ prioAGDBJoined
90
+
91
+ prioAGClusterQuorumState
92
+ prioAGClusterMemberState
93
+ prioAGClusterMemberQuorumVotes
94
+
95
+ prioAGThreads
96
+
97
+ prioAGPageRepair
98
)
99
100
// instanceCharts are charts for the SQL Server instance level metrics
@@ -898,3 +924,414 @@ func cleanPublicationName(pubDB, publication string) string {
924
r := strings.NewReplacer(" ", "_", ".", "_", "-", "_")
925
return strings.ToLower(r.Replace(pubDB + "_" + publication))
926
}
927
+
928
+func cleanAGName(name string) string {
929
+ r := strings.NewReplacer(" ", "_", ".", "_", "-", "_", "\\", "_")
930
+ return strings.ToLower(r.Replace(name))
931
+}
932
+
933
+func cleanAGReplicaName(agName, replicaServer string) string {
934
+ return cleanAGName(agName) + "_" + cleanAGName(replicaServer)
935
+}
936
+
937
+func cleanAGDatabaseReplicaName(agName, replicaServer, dbName string) string {
938
+ return cleanAGName(agName) + "_" + cleanAGName(replicaServer) + "_" + cleanDatabaseName(dbName)
939
+}
940
+
941
+// AG-level chart templates
942
+var (
943
+ agSyncHealthChartTmpl = collectorapi.Chart{
944
+ ID: "ag_%s_sync_health",
945
+ Title: "Availability group synchronization health",
946
+ Units: "state",
947
+ Fam: "ag health",
948
+ Ctx: "mssql.ag_sync_health",
949
+ Priority: prioAGSyncHealth,
950
+ Dims: collectorapi.Dims{
951
+ {ID: "ag_%s_sync_health_not_healthy", Name: "not_healthy"},
952
+ {ID: "ag_%s_sync_health_partially_healthy", Name: "partially_healthy"},
953
+ {ID: "ag_%s_sync_health_healthy", Name: "healthy"},
954
+ },
955
+ }
956
+ agRecoveryHealthChartTmpl = collectorapi.Chart{
957
+ ID: "ag_%s_recovery_health",
958
+ Title: "Availability group recovery health",
959
+ Units: "state",
960
+ Fam: "ag health",
961
+ Ctx: "mssql.ag_recovery_health",
962
+ Priority: prioAGRecoveryHealth,
963
+ Dims: collectorapi.Dims{
964
+ {ID: "ag_%s_primary_recovery_online", Name: "primary_online"},
965
+ {ID: "ag_%s_primary_recovery_in_progress", Name: "primary_in_progress"},
966
+ {ID: "ag_%s_secondary_recovery_online", Name: "secondary_online"},
967
+ {ID: "ag_%s_secondary_recovery_in_progress", Name: "secondary_in_progress"},
968
+ },
969
+ }
970
+)
971
+
972
+// Replica-level chart templates
973
+var (
974
+ agReplicaRoleChartTmpl = collectorapi.Chart{
975
+ ID: "ag_replica_%s_role",
976
+ Title: "Availability group replica role",
977
+ Units: "state",
978
+ Fam: "ag replicas",
979
+ Ctx: "mssql.ag_replica_role",
980
+ Priority: prioAGReplicaRole,
981
+ Dims: collectorapi.Dims{
982
+ {ID: "ag_replica_%s_role_primary", Name: "primary"},
983
+ {ID: "ag_replica_%s_role_secondary", Name: "secondary"},
984
+ {ID: "ag_replica_%s_role_resolving", Name: "resolving"},
985
+ {ID: "ag_replica_%s_role_unknown", Name: "unknown"},
986
+ },
987
+ }
988
+ agReplicaConnectedStateChartTmpl = collectorapi.Chart{
989
+ ID: "ag_replica_%s_connected_state",
990
+ Title: "Availability group replica connected state",
991
+ Units: "state",
992
+ Fam: "ag replicas",
993
+ Ctx: "mssql.ag_replica_connected_state",
994
+ Priority: prioAGReplicaConnectedState,
995
+ Dims: collectorapi.Dims{
996
+ {ID: "ag_replica_%s_connected", Name: "connected"},
997
+ {ID: "ag_replica_%s_disconnected", Name: "disconnected"},
998
+ {ID: "ag_replica_%s_conn_unknown", Name: "unknown"},
999
+ },
1000
+ }
1001
+ agReplicaSyncHealthChartTmpl = collectorapi.Chart{
1002
+ ID: "ag_replica_%s_sync_health",
1003
+ Title: "Availability group replica synchronization health",
1004
+ Units: "state",
1005
+ Fam: "ag replicas",
1006
+ Ctx: "mssql.ag_replica_sync_health",
1007
+ Priority: prioAGReplicaSyncHealth,
1008
+ Dims: collectorapi.Dims{
1009
+ {ID: "ag_replica_%s_sync_health_not_healthy", Name: "not_healthy"},
1010
+ {ID: "ag_replica_%s_sync_health_partially_healthy", Name: "partially_healthy"},
1011
+ {ID: "ag_replica_%s_sync_health_healthy", Name: "healthy"},
1012
+ },
1013
+ }
1014
+)
1015
+
1016
+// Database-replica-level chart templates
1017
+var (
1018
+ agDBSyncStateChartTmpl = collectorapi.Chart{
1019
+ ID: "ag_db_%s_sync_state",
1020
+ Title: "AG database synchronization state",
1021
+ Units: "state",
1022
+ Fam: "ag databases",
1023
+ Ctx: "mssql.ag_db_sync_state",
1024
+ Priority: prioAGDBSyncState,
1025
+ Dims: collectorapi.Dims{
1026
+ {ID: "ag_db_%s_sync_state_not_synchronizing", Name: "not_synchronizing"},
1027
+ {ID: "ag_db_%s_sync_state_synchronizing", Name: "synchronizing"},
1028
+ {ID: "ag_db_%s_sync_state_synchronized", Name: "synchronized"},
1029
+ {ID: "ag_db_%s_sync_state_reverting", Name: "reverting"},
1030
+ {ID: "ag_db_%s_sync_state_initializing", Name: "initializing"},
1031
+ },
1032
+ }
1033
+ agDBLogSendQueueChartTmpl = collectorapi.Chart{
1034
+ ID: "ag_db_%s_log_send_queue",
1035
+ Title: "AG database log send queue size",
1036
+ Units: "bytes",
1037
+ Fam: "ag databases",
1038
+ Ctx: "mssql.ag_db_log_send_queue",
1039
+ Priority: prioAGDBLogSendQueue,
1040
+ Dims: collectorapi.Dims{
1041
+ {ID: "ag_db_%s_log_send_queue_size", Name: "queue_size"},
1042
+ },
1043
+ }
1044
+ agDBLogSendRateChartTmpl = collectorapi.Chart{
1045
+ ID: "ag_db_%s_log_send_rate",
1046
+ Title: "AG database log send rate",
1047
+ Units: "bytes/s",
1048
+ Fam: "ag databases",
1049
+ Ctx: "mssql.ag_db_log_send_rate",
1050
+ Priority: prioAGDBLogSendRate,
1051
+ Dims: collectorapi.Dims{
1052
+ {ID: "ag_db_%s_log_send_rate", Name: "send_rate"},
1053
+ },
1054
+ }
1055
+ agDBRedoQueueChartTmpl = collectorapi.Chart{
1056
+ ID: "ag_db_%s_redo_queue",
1057
+ Title: "AG database redo queue size",
1058
+ Units: "bytes",
1059
+ Fam: "ag databases",
1060
+ Ctx: "mssql.ag_db_redo_queue",
1061
+ Priority: prioAGDBRedoQueue,
1062
+ Dims: collectorapi.Dims{
1063
+ {ID: "ag_db_%s_redo_queue_size", Name: "queue_size"},
1064
+ },
1065
+ }
1066
+ agDBRedoRateChartTmpl = collectorapi.Chart{
1067
+ ID: "ag_db_%s_redo_rate",
1068
+ Title: "AG database redo rate",
1069
+ Units: "bytes/s",
1070
+ Fam: "ag databases",
1071
+ Ctx: "mssql.ag_db_redo_rate",
1072
+ Priority: prioAGDBRedoRate,
1073
+ Dims: collectorapi.Dims{
1074
+ {ID: "ag_db_%s_redo_rate", Name: "redo_rate"},
1075
+ },
1076
+ }
1077
+ agDBFilestreamSendRateChartTmpl = collectorapi.Chart{
1078
+ ID: "ag_db_%s_filestream_send_rate",
1079
+ Title: "AG database filestream send rate",
1080
+ Units: "bytes/s",
1081
+ Fam: "ag databases",
1082
+ Ctx: "mssql.ag_db_filestream_send_rate",
1083
+ Priority: prioAGDBFilestreamSendRate,
1084
+ Dims: collectorapi.Dims{
1085
+ {ID: "ag_db_%s_filestream_send_rate", Name: "send_rate"},
1086
+ },
1087
+ }
1088
+ agDBSecondaryLagChartTmpl = collectorapi.Chart{
1089
+ ID: "ag_db_%s_secondary_lag",
1090
+ Title: "AG database secondary lag",
1091
+ Units: "seconds",
1092
+ Fam: "ag databases",
1093
+ Ctx: "mssql.ag_db_secondary_lag",
1094
+ Priority: prioAGDBSecondaryLag,
1095
+ Dims: collectorapi.Dims{
1096
+ {ID: "ag_db_%s_secondary_lag_seconds", Name: "lag"},
1097
+ },
1098
+ }
1099
+ agDBSuspendedChartTmpl = collectorapi.Chart{
1100
+ ID: "ag_db_%s_suspended",
1101
+ Title: "AG database data movement suspended state",
1102
+ Units: "state",
1103
+ Fam: "ag databases",
1104
+ Ctx: "mssql.ag_db_suspended",
1105
+ Priority: prioAGDBSuspended,
1106
+ Dims: collectorapi.Dims{
1107
+ {ID: "ag_db_%s_not_suspended", Name: "active"},
1108
+ {ID: "ag_db_%s_suspended", Name: "suspended"},
1109
+ },
1110
+ }
1111
+ agDBFailoverReadinessChartTmpl = collectorapi.Chart{
1112
+ ID: "ag_db_%s_failover_readiness",
1113
+ Title: "AG database failover readiness",
1114
+ Units: "state",
1115
+ Fam: "ag databases",
1116
+ Ctx: "mssql.ag_db_failover_readiness",
1117
+ Priority: prioAGDBFailoverReadiness,
1118
+ Dims: collectorapi.Dims{
1119
+ {ID: "ag_db_%s_failover_ready", Name: "ready"},
1120
+ {ID: "ag_db_%s_failover_not_ready", Name: "not_ready"},
1121
+ },
1122
+ }
1123
+ agDBJoinedChartTmpl = collectorapi.Chart{
1124
+ ID: "ag_db_%s_joined_state",
1125
+ Title: "AG database joined state",
1126
+ Units: "state",
1127
+ Fam: "ag databases",
1128
+ Ctx: "mssql.ag_db_joined_state",
1129
+ Priority: prioAGDBJoined,
1130
+ Dims: collectorapi.Dims{
1131
+ {ID: "ag_db_%s_joined", Name: "joined"},
1132
+ {ID: "ag_db_%s_not_joined", Name: "not_joined"},
1133
+ },
1134
+ }
1135
+)
1136
+
1137
+// Cluster-level chart
1138
+var agClusterQuorumStateChart = collectorapi.Chart{
1139
+ ID: "ag_cluster_quorum_state",
1140
+ Title: "WSFC cluster quorum state",
1141
+ Units: "state",
1142
+ Fam: "ag cluster",
1143
+ Ctx: "mssql.ag_cluster_quorum_state",
1144
+ Priority: prioAGClusterQuorumState,
1145
+ Dims: collectorapi.Dims{
1146
+ {ID: "ag_cluster_quorum_state_normal", Name: "normal"},
1147
+ {ID: "ag_cluster_quorum_state_forced", Name: "forced"},
1148
+ {ID: "ag_cluster_quorum_state_unknown", Name: "unknown"},
1149
+ },
1150
+}
1151
+
1152
+// Cluster member chart templates
1153
+var (
1154
+ agClusterMemberStateChartTmpl = collectorapi.Chart{
1155
+ ID: "ag_cluster_member_%s_state",
1156
+ Title: "WSFC cluster member state",
1157
+ Units: "state",
1158
+ Fam: "ag cluster",
1159
+ Ctx: "mssql.ag_cluster_member_state",
1160
+ Priority: prioAGClusterMemberState,
1161
+ Dims: collectorapi.Dims{
1162
+ {ID: "ag_cluster_member_%s_up", Name: "up"},
1163
+ {ID: "ag_cluster_member_%s_down", Name: "down"},
1164
+ },
1165
+ }
1166
+ agClusterMemberQuorumVotesChartTmpl = collectorapi.Chart{
1167
+ ID: "ag_cluster_member_%s_quorum_votes",
1168
+ Title: "WSFC cluster member quorum votes",
1169
+ Units: "votes",
1170
+ Fam: "ag cluster",
1171
+ Ctx: "mssql.ag_cluster_member_quorum_votes",
1172
+ Priority: prioAGClusterMemberQuorumVotes,
1173
+ Dims: collectorapi.Dims{
1174
+ {ID: "ag_cluster_member_%s_quorum_votes", Name: "votes"},
1175
+ },
1176
+ }
1177
+)
1178
+
1179
+// Page repair chart template (keyed by database name)
1180
+var agPageRepairChartTmpl = collectorapi.Chart{
1181
+ ID: "ag_page_repair_%s",
1182
+ Title: "AG automatic page repair events",
1183
+ Units: "repairs",
1184
+ Fam: "ag page repair",
1185
+ Ctx: "mssql.ag_page_repair",
1186
+ Priority: prioAGPageRepair,
1187
+ Dims: collectorapi.Dims{
1188
+ {ID: "ag_page_repair_%s_successful", Name: "successful"},
1189
+ {ID: "ag_page_repair_%s_failed", Name: "failed"},
1190
+ },
1191
+}
1192
+
1193
+// Thread chart template
1194
+var agThreadsChartTmpl = collectorapi.Chart{
1195
+ ID: "ag_%s_threads",
1196
+ Title: "Availability group threads",
1197
+ Units: "threads",
1198
+ Fam: "ag threads",
1199
+ Ctx: "mssql.ag_threads",
1200
+ Priority: prioAGThreads,
1201
+ Dims: collectorapi.Dims{
1202
+ {ID: "ag_%s_capture_threads", Name: "capture"},
1203
+ {ID: "ag_%s_redo_threads", Name: "redo"},
1204
+ {ID: "ag_%s_parallel_redo_threads", Name: "parallel_redo"},
1205
+ },
1206
+}
1207
+
1208
+func (c *Collector) addAGCharts(agName string) {
1209
+ charts := &collectorapi.Charts{
1210
+ agSyncHealthChartTmpl.Copy(),
1211
+ agRecoveryHealthChartTmpl.Copy(),
1212
+ }
1213
+
1214
+ if c.majorVersion >= 15 { // SQL Server 2019+
1215
+ *charts = append(*charts, agThreadsChartTmpl.Copy())
1216
+ }
1217
+
1218
+ agID := cleanAGName(agName)
1219
+
1220
+ for _, chart := range *charts {
1221
+ chart.ID = fmt.Sprintf(chart.ID, agID)
1222
+ chart.Labels = []collectorapi.Label{
1223
+ {Key: "ag_name", Value: agName},
1224
+ }
1225
+ for _, dim := range chart.Dims {
1226
+ dim.ID = fmt.Sprintf(dim.ID, agID)
1227
+ }
1228
+ }
1229
+
1230
+ if err := c.Charts().Add(*charts...); err != nil {
1231
+ c.Warning(err)
1232
+ }
1233
+}
1234
+
1235
+func (c *Collector) addAGReplicaCharts(agName, replicaServer, availMode, failoverMode string) {
1236
+ charts := &collectorapi.Charts{
1237
+ agReplicaRoleChartTmpl.Copy(),
1238
+ agReplicaConnectedStateChartTmpl.Copy(),
1239
+ agReplicaSyncHealthChartTmpl.Copy(),
1240
+ }
1241
+
1242
+ rID := cleanAGReplicaName(agName, replicaServer)
1243
+
1244
+ for _, chart := range *charts {
1245
+ chart.ID = fmt.Sprintf(chart.ID, rID)
1246
+ chart.Labels = []collectorapi.Label{
1247
+ {Key: "ag_name", Value: agName},
1248
+ {Key: "replica_server", Value: replicaServer},
1249
+ {Key: "availability_mode", Value: availMode},
1250
+ {Key: "failover_mode", Value: failoverMode},
1251
+ }
1252
+ for _, dim := range chart.Dims {
1253
+ dim.ID = fmt.Sprintf(dim.ID, rID)
1254
+ }
1255
+ }
1256
+
1257
+ if err := c.Charts().Add(*charts...); err != nil {
1258
+ c.Warning(err)
1259
+ }
1260
+}
1261
+
1262
+func (c *Collector) addAGDatabaseReplicaCharts(agName, replicaServer, dbName string) {
1263
+ charts := &collectorapi.Charts{
1264
+ agDBSyncStateChartTmpl.Copy(),
1265
+ agDBLogSendQueueChartTmpl.Copy(),
1266
+ agDBLogSendRateChartTmpl.Copy(),
1267
+ agDBRedoQueueChartTmpl.Copy(),
1268
+ agDBRedoRateChartTmpl.Copy(),
1269
+ agDBFilestreamSendRateChartTmpl.Copy(),
1270
+ agDBSuspendedChartTmpl.Copy(),
1271
+ agDBFailoverReadinessChartTmpl.Copy(),
1272
+ agDBJoinedChartTmpl.Copy(),
1273
+ }
1274
+
1275
+ if c.majorVersion >= 13 { // SQL Server 2016+
1276
+ *charts = append(*charts, agDBSecondaryLagChartTmpl.Copy())
1277
+ }
1278
+
1279
+ drID := cleanAGDatabaseReplicaName(agName, replicaServer, dbName)
1280
+
1281
+ for _, chart := range *charts {
1282
+ chart.ID = fmt.Sprintf(chart.ID, drID)
1283
+ chart.Labels = []collectorapi.Label{
1284
+ {Key: "ag_name", Value: agName},
1285
+ {Key: "replica_server", Value: replicaServer},
1286
+ {Key: "database", Value: dbName},
1287
+ }
1288
+ for _, dim := range chart.Dims {
1289
+ dim.ID = fmt.Sprintf(dim.ID, drID)
1290
+ }
1291
+ }
1292
+
1293
+ if err := c.Charts().Add(*charts...); err != nil {
1294
+ c.Warning(err)
1295
+ }
1296
+}
1297
+
1298
+func (c *Collector) addAGClusterMemberCharts(memberName string) {
1299
+ charts := &collectorapi.Charts{
1300
+ agClusterMemberStateChartTmpl.Copy(),
1301
+ agClusterMemberQuorumVotesChartTmpl.Copy(),
1302
+ }
1303
+
1304
+ mID := cleanAGName(memberName)
1305
+
1306
+ for _, chart := range *charts {
1307
+ chart.ID = fmt.Sprintf(chart.ID, mID)
1308
+ chart.Labels = []collectorapi.Label{
1309
+ {Key: "cluster_member", Value: memberName},
1310
+ }
1311
+ for _, dim := range chart.Dims {
1312
+ dim.ID = fmt.Sprintf(dim.ID, mID)
1313
+ }
1314
+ }
1315
+
1316
+ if err := c.Charts().Add(*charts...); err != nil {
1317
+ c.Warning(err)
1318
+ }
1319
+}
1320
+
1321
+func (c *Collector) addAGPageRepairCharts(dbName string) {
1322
+ chart := agPageRepairChartTmpl.Copy()
1323
+
1324
+ dbID := cleanDatabaseName(dbName)
1325
+
1326
+ chart.ID = fmt.Sprintf(chart.ID, dbID)
1327
+ chart.Labels = []collectorapi.Label{
1328
+ {Key: "database", Value: dbName},
1329
+ }
1330
+ for _, dim := range chart.Dims {
1331
+ dim.ID = fmt.Sprintf(dim.ID, dbID)
1332
+ }
1333
+
1334
+ if err := c.Charts().Add(chart); err != nil {
1335
+ c.Warning(err)
1336
+ }
1337
+}
src/go/plugin/go.d/collector/mssql/collect.go
+412
-1
@@ -6,6 +6,7 @@ import (
6
"context"
7
"database/sql"
8
"fmt"
9
+ "strconv"
10
"strings"
11
"time"
12
@@ -30,7 +31,15 @@ func (c *Collector) collect() (map[string]int64, error) {
31
return nil, fmt.Errorf("failed to query version: %v", err)
32
}
33
c.version = ver
33
- c.Debugf("connected to SQL Server version %s", c.version)
34
+ c.majorVersion = parseMajorVersion(c.version)
35
+ c.Debugf("connected to SQL Server version %s (major: %d)", c.version, c.majorVersion)
36
+ }
37
+
38
+ if !c.hadrChecked {
39
+ if err := c.checkHadrEnabled(); err != nil {
40
+ c.Debugf("HADR check failed: %v", err)
41
+ }
42
+ c.hadrChecked = true
43
}
44
45
mx := make(map[string]int64)
@@ -53,6 +62,11 @@ func (c *Collector) collect() (map[string]int64, error) {
62
if err := c.collectReplicationStatus(mx); err != nil {
63
return nil, err
64
}
65
+ if c.hadrEnabled {
66
+ if err := c.collectAvailabilityGroups(mx); err != nil {
67
+ c.Warningf("AG metrics collection failed: %v", err)
68
+ }
69
+ }
70
71
return mx, nil
72
}
@@ -729,6 +743,403 @@ func boolToInt(b bool) int64 {
743
return 0
744
}
745
746
+// parseMajorVersion extracts the major version from a string like "16.0.4175.1"
747
+func parseMajorVersion(version string) int {
748
+ parts := strings.SplitN(version, ".", 2)
749
+ if len(parts) == 0 {
750
+ return 0
751
+ }
752
+ v, err := strconv.Atoi(parts[0])
753
+ if err != nil {
754
+ return 0
755
+ }
756
+ return v
757
+}
758
+
759
+func (c *Collector) checkHadrEnabled() error {
760
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
761
+ defer cancel()
762
+
763
+ var enabled sql.NullInt64
764
+ err := c.db.QueryRowContext(ctx, queryHadrEnabled).Scan(&enabled)
765
+ if err != nil {
766
+ return fmt.Errorf("HADR enabled check failed: %v", err)
767
+ }
768
+
769
+ c.hadrEnabled = enabled.Valid && enabled.Int64 == 1
770
+
771
+ if c.hadrEnabled {
772
+ c.Debugf("Always On Availability Groups is enabled")
773
+ } else {
774
+ c.Debugf("Always On Availability Groups is not enabled, skipping AG metrics")
775
+ }
776
+
777
+ return nil
778
+}
779
+
780
+// agDatabaseReplicaQuery returns the appropriate query for the SQL Server version
781
+func agDatabaseReplicaQuery(majorVersion int) string {
782
+ if majorVersion >= 13 { // SQL Server 2016+
783
+ return queryAGDatabaseReplicas16
784
+ }
785
+ return queryAGDatabaseReplicasPre16 // SQL Server 2012-2014
786
+}
787
+
788
+func (c *Collector) collectAvailabilityGroups(mx map[string]int64) error {
789
+ if err := c.collectAGHealth(mx); err != nil {
790
+ return err
791
+ }
792
+ if err := c.collectAGReplicaStates(mx); err != nil {
793
+ return err
794
+ }
795
+ if err := c.collectAGDatabaseReplicas(mx); err != nil {
796
+ return err
797
+ }
798
+ if err := c.collectAGCluster(mx); err != nil {
799
+ c.Debugf("AG cluster query failed (WSFC may not be configured): %v", err)
800
+ }
801
+ if err := c.collectAGClusterMembers(mx); err != nil {
802
+ c.Debugf("AG cluster members query failed: %v", err)
803
+ }
804
+ if err := c.collectAGFailoverReadiness(mx); err != nil {
805
+ c.Debugf("AG failover readiness query failed: %v", err)
806
+ }
807
+ if err := c.collectAGAutoPageRepair(mx); err != nil {
808
+ c.Debugf("AG auto page repair query failed: %v", err)
809
+ }
810
+ if c.majorVersion >= 15 { // SQL Server 2019+
811
+ if err := c.collectAGThreads(mx); err != nil {
812
+ c.Debugf("AG threads query failed: %v", err)
813
+ }
814
+ }
815
+ return nil
816
+}
817
+
818
+func (c *Collector) collectAGHealth(mx map[string]int64) error {
819
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
820
+ defer cancel()
821
+
822
+ rows, err := c.db.QueryContext(ctx, queryAGHealth)
823
+ if err != nil {
824
+ return fmt.Errorf("AG health query failed: %v", err)
825
+ }
826
+ defer rows.Close()
827
+
828
+ for rows.Next() {
829
+ var agName string
830
+ var syncHealth, primaryRecoveryHealth, secondaryRecoveryHealth int64
831
+ if err := rows.Scan(&agName, &syncHealth, &primaryRecoveryHealth, &secondaryRecoveryHealth); err != nil {
832
+ c.Debugf("AG health scan failed: %v", err)
833
+ continue
834
+ }
835
+
836
+ agName = strings.TrimSpace(agName)
837
+
838
+ if !c.seenAGs[agName] {
839
+ c.seenAGs[agName] = true
840
+ c.addAGCharts(agName)
841
+ }
842
+
843
+ agID := cleanAGName(agName)
844
+
845
+ // sync health: 0=not_healthy, 1=partially_healthy, 2=healthy
846
+ mx[fmt.Sprintf("ag_%s_sync_health_not_healthy", agID)] = boolToInt(syncHealth == 0)
847
+ mx[fmt.Sprintf("ag_%s_sync_health_partially_healthy", agID)] = boolToInt(syncHealth == 1)
848
+ mx[fmt.Sprintf("ag_%s_sync_health_healthy", agID)] = boolToInt(syncHealth == 2)
849
+
850
+ // primary recovery health: -1=N/A (on secondary), 0=in_progress, 1=online
851
+ mx[fmt.Sprintf("ag_%s_primary_recovery_online", agID)] = boolToInt(primaryRecoveryHealth == 1)
852
+ mx[fmt.Sprintf("ag_%s_primary_recovery_in_progress", agID)] = boolToInt(primaryRecoveryHealth == 0)
853
+
854
+ // secondary recovery health: -1=N/A (on primary), 0=in_progress, 1=online
855
+ mx[fmt.Sprintf("ag_%s_secondary_recovery_online", agID)] = boolToInt(secondaryRecoveryHealth == 1)
856
+ mx[fmt.Sprintf("ag_%s_secondary_recovery_in_progress", agID)] = boolToInt(secondaryRecoveryHealth == 0)
857
+ }
858
+
859
+ return rows.Err()
860
+}
861
+
862
+func (c *Collector) collectAGReplicaStates(mx map[string]int64) error {
863
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
864
+ defer cancel()
865
+
866
+ rows, err := c.db.QueryContext(ctx, queryAGReplicaStates)
867
+ if err != nil {
868
+ return fmt.Errorf("AG replica states query failed: %v", err)
869
+ }
870
+ defer rows.Close()
871
+
872
+ for rows.Next() {
873
+ var agName, replicaServer, availMode, failoverMode string
874
+ var role, connState, syncHealth int64
875
+ if err := rows.Scan(&agName, &replicaServer, &availMode, &failoverMode,
876
+ &role, &connState, &syncHealth); err != nil {
877
+ c.Debugf("AG replica state scan failed: %v", err)
878
+ continue
879
+ }
880
+
881
+ agName = strings.TrimSpace(agName)
882
+ replicaServer = strings.TrimSpace(replicaServer)
883
+
884
+ replicaKey := agName + "_" + replicaServer
885
+ if !c.seenAGReplicas[replicaKey] {
886
+ c.seenAGReplicas[replicaKey] = true
887
+ c.addAGReplicaCharts(agName, replicaServer, availMode, failoverMode)
888
+ }
889
+
890
+ rID := cleanAGReplicaName(agName, replicaServer)
891
+
892
+ // role: -1=unknown, 0=resolving, 1=primary, 2=secondary
893
+ mx[fmt.Sprintf("ag_replica_%s_role_resolving", rID)] = boolToInt(role == 0)
894
+ mx[fmt.Sprintf("ag_replica_%s_role_primary", rID)] = boolToInt(role == 1)
895
+ mx[fmt.Sprintf("ag_replica_%s_role_secondary", rID)] = boolToInt(role == 2)
896
+ mx[fmt.Sprintf("ag_replica_%s_role_unknown", rID)] = boolToInt(role == -1)
897
+
898
+ // connected state: -1=unknown, 0=disconnected, 1=connected
899
+ mx[fmt.Sprintf("ag_replica_%s_connected", rID)] = boolToInt(connState == 1)
900
+ mx[fmt.Sprintf("ag_replica_%s_disconnected", rID)] = boolToInt(connState == 0)
901
+ mx[fmt.Sprintf("ag_replica_%s_conn_unknown", rID)] = boolToInt(connState == -1)
902
+
903
+ // sync health: 0=not_healthy, 1=partially_healthy, 2=healthy
904
+ mx[fmt.Sprintf("ag_replica_%s_sync_health_not_healthy", rID)] = boolToInt(syncHealth == 0)
905
+ mx[fmt.Sprintf("ag_replica_%s_sync_health_partially_healthy", rID)] = boolToInt(syncHealth == 1)
906
+ mx[fmt.Sprintf("ag_replica_%s_sync_health_healthy", rID)] = boolToInt(syncHealth == 2)
907
+ }
908
+
909
+ return rows.Err()
910
+}
911
+
912
+func (c *Collector) collectAGDatabaseReplicas(mx map[string]int64) error {
913
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
914
+ defer cancel()
915
+
916
+ query := agDatabaseReplicaQuery(c.majorVersion)
917
+ rows, err := c.db.QueryContext(ctx, query)
918
+ if err != nil {
919
+ return fmt.Errorf("AG database replicas query failed: %v", err)
920
+ }
921
+ defer rows.Close()
922
+
923
+ for rows.Next() {
924
+ var agName, replicaServer, dbName string
925
+ var syncState, isSuspended int64
926
+ var logSendQueue, logSendRate, redoQueue, redoRate, filestreamRate int64
927
+ var secondaryLag int64
928
+
929
+ if err := rows.Scan(
930
+ &agName, &replicaServer, &dbName,
931
+ &syncState, &isSuspended,
932
+ &logSendQueue, &logSendRate,
933
+ &redoQueue, &redoRate, &filestreamRate,
934
+ &secondaryLag,
935
+ ); err != nil {
936
+ c.Debugf("AG database replica scan failed: %v", err)
937
+ continue
938
+ }
939
+
940
+ agName = strings.TrimSpace(agName)
941
+ replicaServer = strings.TrimSpace(replicaServer)
942
+ dbName = strings.TrimSpace(dbName)
943
+
944
+ if dbName == "" {
945
+ continue
946
+ }
947
+
948
+ drKey := agName + "_" + replicaServer + "_" + dbName
949
+ if !c.seenAGDatabaseReplicas[drKey] {
950
+ c.seenAGDatabaseReplicas[drKey] = true
951
+ c.addAGDatabaseReplicaCharts(agName, replicaServer, dbName)
952
+ }
953
+
954
+ drID := cleanAGDatabaseReplicaName(agName, replicaServer, dbName)
955
+
956
+ // sync state: 0=not_synchronizing, 1=synchronizing, 2=synchronized, 3=reverting, 4=initializing
957
+ mx[fmt.Sprintf("ag_db_%s_sync_state_not_synchronizing", drID)] = boolToInt(syncState == 0)
958
+ mx[fmt.Sprintf("ag_db_%s_sync_state_synchronizing", drID)] = boolToInt(syncState == 1)
959
+ mx[fmt.Sprintf("ag_db_%s_sync_state_synchronized", drID)] = boolToInt(syncState == 2)
960
+ mx[fmt.Sprintf("ag_db_%s_sync_state_reverting", drID)] = boolToInt(syncState == 3)
961
+ mx[fmt.Sprintf("ag_db_%s_sync_state_initializing", drID)] = boolToInt(syncState == 4)
962
+
963
+ // queue sizes and rates (already converted to bytes in SQL)
964
+ mx[fmt.Sprintf("ag_db_%s_log_send_queue_size", drID)] = logSendQueue
965
+ mx[fmt.Sprintf("ag_db_%s_log_send_rate", drID)] = logSendRate
966
+ mx[fmt.Sprintf("ag_db_%s_redo_queue_size", drID)] = redoQueue
967
+ mx[fmt.Sprintf("ag_db_%s_redo_rate", drID)] = redoRate
968
+ mx[fmt.Sprintf("ag_db_%s_filestream_send_rate", drID)] = filestreamRate
969
+
970
+ // suspended
971
+ mx[fmt.Sprintf("ag_db_%s_suspended", drID)] = isSuspended
972
+ mx[fmt.Sprintf("ag_db_%s_not_suspended", drID)] = boolToInt(isSuspended == 0)
973
+
974
+ // secondary lag (only meaningful on SQL 2016+, -1 = not available)
975
+ if secondaryLag >= 0 {
976
+ mx[fmt.Sprintf("ag_db_%s_secondary_lag_seconds", drID)] = secondaryLag
977
+ }
978
+ }
979
+
980
+ return rows.Err()
981
+}
982
+
983
+func (c *Collector) collectAGCluster(mx map[string]int64) error {
984
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
985
+ defer cancel()
986
+
987
+ var quorumState int64
988
+ err := c.db.QueryRowContext(ctx, queryAGCluster).Scan(&quorumState)
989
+ if err != nil {
990
+ return fmt.Errorf("AG cluster query failed: %v", err)
991
+ }
992
+
993
+ if !c.agClusterChartAdded {
994
+ c.agClusterChartAdded = true
995
+ if err := c.Charts().Add(agClusterQuorumStateChart.Copy()); err != nil {
996
+ c.Warning(err)
997
+ }
998
+ }
999
+
1000
+ // quorum state: 0=unknown, 1=normal, 2=forced
1001
+ mx["ag_cluster_quorum_state_unknown"] = boolToInt(quorumState == 0)
1002
+ mx["ag_cluster_quorum_state_normal"] = boolToInt(quorumState == 1)
1003
+ mx["ag_cluster_quorum_state_forced"] = boolToInt(quorumState == 2)
1004
+
1005
+ return nil
1006
+}
1007
+
1008
+func (c *Collector) collectAGClusterMembers(mx map[string]int64) error {
1009
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
1010
+ defer cancel()
1011
+
1012
+ rows, err := c.db.QueryContext(ctx, queryAGClusterMembers)
1013
+ if err != nil {
1014
+ return fmt.Errorf("AG cluster members query failed: %v", err)
1015
+ }
1016
+ defer rows.Close()
1017
+
1018
+ for rows.Next() {
1019
+ var memberName string
1020
+ var memberState, quorumVotes int64
1021
+ if err := rows.Scan(&memberName, &memberState, &quorumVotes); err != nil {
1022
+ c.Debugf("AG cluster member scan failed: %v", err)
1023
+ continue
1024
+ }
1025
+
1026
+ memberName = strings.TrimSpace(memberName)
1027
+
1028
+ if !c.seenAGClusterMembers[memberName] {
1029
+ c.seenAGClusterMembers[memberName] = true
1030
+ c.addAGClusterMemberCharts(memberName)
1031
+ }
1032
+
1033
+ mID := cleanAGName(memberName)
1034
+
1035
+ // member state: 0=offline, 1=online
1036
+ mx[fmt.Sprintf("ag_cluster_member_%s_up", mID)] = boolToInt(memberState == 1)
1037
+ mx[fmt.Sprintf("ag_cluster_member_%s_down", mID)] = boolToInt(memberState == 0)
1038
+ mx[fmt.Sprintf("ag_cluster_member_%s_quorum_votes", mID)] = quorumVotes
1039
+ }
1040
+
1041
+ return rows.Err()
1042
+}
1043
+
1044
+func (c *Collector) collectAGFailoverReadiness(mx map[string]int64) error {
1045
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
1046
+ defer cancel()
1047
+
1048
+ rows, err := c.db.QueryContext(ctx, queryAGFailoverReadiness)
1049
+ if err != nil {
1050
+ return fmt.Errorf("AG failover readiness query failed: %v", err)
1051
+ }
1052
+ defer rows.Close()
1053
+
1054
+ for rows.Next() {
1055
+ var agName, replicaServer, dbName string
1056
+ var isFailoverReady, isDatabaseJoined int64
1057
+ if err := rows.Scan(&agName, &replicaServer, &dbName, &isFailoverReady, &isDatabaseJoined); err != nil {
1058
+ c.Debugf("AG failover readiness scan failed: %v", err)
1059
+ continue
1060
+ }
1061
+
1062
+ agName = strings.TrimSpace(agName)
1063
+ replicaServer = strings.TrimSpace(replicaServer)
1064
+ dbName = strings.TrimSpace(dbName)
1065
+
1066
+ drID := cleanAGDatabaseReplicaName(agName, replicaServer, dbName)
1067
+
1068
+ mx[fmt.Sprintf("ag_db_%s_failover_ready", drID)] = isFailoverReady
1069
+ mx[fmt.Sprintf("ag_db_%s_failover_not_ready", drID)] = boolToInt(isFailoverReady == 0)
1070
+ mx[fmt.Sprintf("ag_db_%s_joined", drID)] = isDatabaseJoined
1071
+ mx[fmt.Sprintf("ag_db_%s_not_joined", drID)] = boolToInt(isDatabaseJoined == 0)
1072
+ }
1073
+
1074
+ return rows.Err()
1075
+}
1076
+
1077
+func (c *Collector) collectAGAutoPageRepair(mx map[string]int64) error {
1078
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
1079
+ defer cancel()
1080
+
1081
+ rows, err := c.db.QueryContext(ctx, queryAGAutoPageRepair)
1082
+ if err != nil {
1083
+ return fmt.Errorf("AG auto page repair query failed: %v", err)
1084
+ }
1085
+ defer rows.Close()
1086
+
1087
+ for rows.Next() {
1088
+ var dbName string
1089
+ var successfulRepairs, failedRepairs int64
1090
+ if err := rows.Scan(&dbName, &successfulRepairs, &failedRepairs); err != nil {
1091
+ c.Debugf("AG page repair scan failed: %v", err)
1092
+ continue
1093
+ }
1094
+
1095
+ dbName = strings.TrimSpace(dbName)
1096
+
1097
+ if dbName == "" {
1098
+ continue
1099
+ }
1100
+
1101
+ if !c.seenAGPageRepairDBs[dbName] {
1102
+ c.seenAGPageRepairDBs[dbName] = true
1103
+ c.addAGPageRepairCharts(dbName)
1104
+ }
1105
+
1106
+ dbID := cleanDatabaseName(dbName)
1107
+
1108
+ mx[fmt.Sprintf("ag_page_repair_%s_successful", dbID)] = successfulRepairs
1109
+ mx[fmt.Sprintf("ag_page_repair_%s_failed", dbID)] = failedRepairs
1110
+ }
1111
+
1112
+ return rows.Err()
1113
+}
1114
+
1115
+func (c *Collector) collectAGThreads(mx map[string]int64) error {
1116
+ ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
1117
+ defer cancel()
1118
+
1119
+ rows, err := c.db.QueryContext(ctx, queryAGThreads)
1120
+ if err != nil {
1121
+ return fmt.Errorf("AG threads query failed: %v", err)
1122
+ }
1123
+ defer rows.Close()
1124
+
1125
+ for rows.Next() {
1126
+ var agName string
1127
+ var captureThreads, redoThreads, parallelRedoThreads int64
1128
+ if err := rows.Scan(&agName, &captureThreads, &redoThreads, ¶llelRedoThreads); err != nil {
1129
+ c.Debugf("AG threads scan failed: %v", err)
1130
+ continue
1131
+ }
1132
+
1133
+ agID := cleanAGName(strings.TrimSpace(agName))
1134
+
1135
+ mx[fmt.Sprintf("ag_%s_capture_threads", agID)] = captureThreads
1136
+ mx[fmt.Sprintf("ag_%s_redo_threads", agID)] = redoThreads
1137
+ mx[fmt.Sprintf("ag_%s_parallel_redo_threads", agID)] = parallelRedoThreads
1138
+ }
1139
+
1140
+ return rows.Err()
1141
+}
1142
+
1143
func (c *Collector) collectProcessMemory(mx map[string]int64) error {
1144
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration())
1145
defer cancel()
src/go/plugin/go.d/collector/mssql/collector.go
+17
@@ -56,6 +56,12 @@ func New() *Collector {
56
seenLockStatsTypes: make(map[string]bool),
57
seenJobs: make(map[string]bool),
58
seenReplications: make(map[string]bool),
59
+
60
+ seenAGs: make(map[string]bool),
61
+ seenAGReplicas: make(map[string]bool),
62
+ seenAGDatabaseReplicas: make(map[string]bool),
63
+ seenAGClusterMembers: make(map[string]bool),
64
+ seenAGPageRepairDBs: make(map[string]bool),
65
}
66
}
67
@@ -156,6 +162,17 @@ type Collector struct {
162
seenJobs map[string]bool
163
seenReplications map[string]bool
164
165
+ hadrEnabled bool // true if Always On AG is enabled on this instance
166
+ hadrChecked bool // true after the HADR check has been performed
167
+ majorVersion int // parsed from version string (11=2012, 12=2014, 13=2016, etc.)
168
+
169
+ seenAGs map[string]bool // key: ag_name
170
+ seenAGReplicas map[string]bool // key: ag_name + "_" + replica_server_name
171
+ seenAGDatabaseReplicas map[string]bool // key: ag_name + "_" + replica_server_name + "_" + db_name
172
+ seenAGClusterMembers map[string]bool // key: member_name
173
+ seenAGPageRepairDBs map[string]bool // key: database_name
174
+ agClusterChartAdded bool // true after cluster quorum chart has been added
175
+
176
// Query Store column cache (per-instance to handle different SQL Server versions)
177
queryStoreColsMu sync.RWMutex // protects queryStoreCols for concurrent access
178
queryStoreCols map[string]bool
src/go/plugin/go.d/collector/mssql/metadata.yaml
+209
@@ -38,6 +38,7 @@ modules:
38
- Dynamic management views (DMVs) for wait statistics, locks, and sessions
39
- Per-database transaction and lock statistics
40
- SQL Server Agent job status
41
+ - Always On Availability Group health, replica states, and per-database synchronization metrics
42
method_description: |
43
It connects to the SQL Server instance via TCP using the go-mssqldb driver and executes queries against:
44
@@ -50,6 +51,13 @@ modules:
51
- `sys.dm_os_sys_memory` - OS physical memory and page file
52
- `sys.master_files` - Database file sizes
53
- `msdb.dbo.sysjobs` - SQL Agent job status
54
+ - `sys.dm_hadr_availability_group_states` - AG health rollup
55
+ - `sys.dm_hadr_availability_replica_states` - Replica operational state
56
+ - `sys.dm_hadr_database_replica_states` - Database sync queues and rates
57
+ - `sys.dm_hadr_cluster` / `sys.dm_hadr_cluster_members` - WSFC cluster health
58
+ - `sys.dm_hadr_database_replica_cluster_states` - Failover readiness
59
+ - `sys.dm_hadr_auto_page_repair` - Automatic page repair events
60
+ - `sys.dm_hadr_ag_threads` - AG thread usage (SQL Server 2019+)
61
default_behavior:
62
auto_detection:
63
description: |
@@ -66,6 +74,8 @@ modules:
74
The monitoring user requires the VIEW SERVER STATE permission to access DMVs.
75
SQL Agent job monitoring is part of collector startup, so access to
76
`msdb.dbo.sysjobs` is required.
77
+ Always On AG monitoring requires VIEW ANY DEFINITION for access to availability group catalog views.
78
+ On SQL Server 2022+, HADR DMVs may additionally require VIEW SERVER PERFORMANCE STATE.
79
supported_platforms:
80
include: []
81
exclude: []
@@ -83,6 +93,12 @@ modules:
93
-- Grant VIEW SERVER STATE (required for DMVs)
94
GRANT VIEW SERVER STATE TO netdata_user;
95
96
+ -- Grant VIEW ANY DEFINITION (required for Always On AG monitoring)
97
+ GRANT VIEW ANY DEFINITION TO netdata_user;
98
+
99
+ -- Grant VIEW SERVER PERFORMANCE STATE (required for HADR DMVs on SQL Server 2022+)
100
+ -- GRANT VIEW SERVER PERFORMANCE STATE TO netdata_user;
101
+
102
-- Grant access to msdb for SQL Agent job monitoring (required)
103
USE msdb;
104
CREATE USER netdata_user FOR LOGIN netdata_user;
@@ -102,6 +118,8 @@ modules:
118
- `SELECT on msdb.dbo.sysjobs` - SQL Agent job status monitoring
119
120
**Optional permissions:**
121
+ - `VIEW ANY DEFINITION` - Always On Availability Group monitoring
122
+ - `VIEW SERVER PERFORMANCE STATE` - HADR DMVs on SQL Server 2022+
123
- `SELECT on distribution.dbo.MSreplication_monitordata` - Replication monitoring
124
- `SELECT on distribution.dbo.MSpublications` - Publication information
125
- `SELECT on distribution.dbo.MSsubscriptions` - Subscription counts
@@ -1261,3 +1279,194 @@ modules:
1279
dimensions:
1280
- name: total
1281
- name: agents_running
1282
+ - name: availability group
1283
+ description: These metrics refer to Always On Availability Groups. Auto-detected when HADR is enabled.
1284
+ labels:
1285
+ - name: ag_name
1286
+ description: Availability group name
1287
+ metrics:
1288
+ - name: mssql.ag_sync_health
1289
+ description: Availability Group Synchronization Health
1290
+ unit: state
1291
+ chart_type: line
1292
+ dimensions:
1293
+ - name: not_healthy
1294
+ - name: partially_healthy
1295
+ - name: healthy
1296
+ - name: mssql.ag_recovery_health
1297
+ description: Availability Group Recovery Health
1298
+ unit: state
1299
+ chart_type: line
1300
+ dimensions:
1301
+ - name: primary_online
1302
+ - name: primary_in_progress
1303
+ - name: secondary_online
1304
+ - name: secondary_in_progress
1305
+ - name: mssql.ag_threads
1306
+ description: Availability Group Threads (SQL Server 2019+)
1307
+ unit: threads
1308
+ chart_type: line
1309
+ dimensions:
1310
+ - name: capture
1311
+ - name: redo
1312
+ - name: parallel_redo
1313
+ - name: availability group replica
1314
+ description: >-
1315
+ These metrics refer to per-replica state within an Availability Group.
1316
+ Note: on secondary replicas, the replica states DMV returns only local information.
1317
+ labels:
1318
+ - name: ag_name
1319
+ description: Availability group name
1320
+ - name: replica_server
1321
+ description: Replica server name
1322
+ - name: availability_mode
1323
+ description: Availability mode (synchronous_commit or asynchronous_commit)
1324
+ - name: failover_mode
1325
+ description: Failover mode (automatic or manual)
1326
+ metrics:
1327
+ - name: mssql.ag_replica_role
1328
+ description: Availability Group Replica Role
1329
+ unit: state
1330
+ chart_type: line
1331
+ dimensions:
1332
+ - name: primary
1333
+ - name: secondary
1334
+ - name: resolving
1335
+ - name: unknown
1336
+ - name: mssql.ag_replica_connected_state
1337
+ description: Availability Group Replica Connected State
1338
+ unit: state
1339
+ chart_type: line
1340
+ dimensions:
1341
+ - name: connected
1342
+ - name: disconnected
1343
+ - name: unknown
1344
+ - name: mssql.ag_replica_sync_health
1345
+ description: Availability Group Replica Synchronization Health
1346
+ unit: state
1347
+ chart_type: line
1348
+ dimensions:
1349
+ - name: not_healthy
1350
+ - name: partially_healthy
1351
+ - name: healthy
1352
+ - name: availability group database replica
1353
+ description: These metrics refer to per-database synchronization within an Availability Group.
1354
+ labels:
1355
+ - name: ag_name
1356
+ description: Availability group name
1357
+ - name: replica_server
1358
+ description: Replica server name
1359
+ - name: database
1360
+ description: Database name
1361
+ metrics:
1362
+ - name: mssql.ag_db_sync_state
1363
+ description: AG Database Synchronization State
1364
+ unit: state
1365
+ chart_type: line
1366
+ dimensions:
1367
+ - name: not_synchronizing
1368
+ - name: synchronizing
1369
+ - name: synchronized
1370
+ - name: reverting
1371
+ - name: initializing
1372
+ - name: mssql.ag_db_log_send_queue
1373
+ description: AG Database Log Send Queue Size
1374
+ unit: bytes
1375
+ chart_type: line
1376
+ dimensions:
1377
+ - name: queue_size
1378
+ - name: mssql.ag_db_log_send_rate
1379
+ description: AG Database Log Send Rate
1380
+ unit: bytes/s
1381
+ chart_type: line
1382
+ dimensions:
1383
+ - name: send_rate
1384
+ - name: mssql.ag_db_redo_queue
1385
+ description: AG Database Redo Queue Size
1386
+ unit: bytes
1387
+ chart_type: line
1388
+ dimensions:
1389
+ - name: queue_size
1390
+ - name: mssql.ag_db_redo_rate
1391
+ description: AG Database Redo Rate (averaged over active redo time since startup)
1392
+ unit: bytes/s
1393
+ chart_type: line
1394
+ dimensions:
1395
+ - name: redo_rate
1396
+ - name: mssql.ag_db_filestream_send_rate
1397
+ description: AG Database Filestream Send Rate
1398
+ unit: bytes/s
1399
+ chart_type: line
1400
+ dimensions:
1401
+ - name: send_rate
1402
+ - name: mssql.ag_db_secondary_lag
1403
+ description: AG Database Secondary Lag (SQL Server 2016+)
1404
+ unit: seconds
1405
+ chart_type: line
1406
+ dimensions:
1407
+ - name: lag
1408
+ - name: mssql.ag_db_suspended
1409
+ description: AG Database Data Movement Suspended State
1410
+ unit: state
1411
+ chart_type: line
1412
+ dimensions:
1413
+ - name: active
1414
+ - name: suspended
1415
+ - name: mssql.ag_db_failover_readiness
1416
+ description: AG Database Failover Readiness
1417
+ unit: state
1418
+ chart_type: line
1419
+ dimensions:
1420
+ - name: ready
1421
+ - name: not_ready
1422
+ - name: mssql.ag_db_joined_state
1423
+ description: AG Database Joined State
1424
+ unit: state
1425
+ chart_type: line
1426
+ dimensions:
1427
+ - name: joined
1428
+ - name: not_joined
1429
+ - name: WSFC cluster
1430
+ description: These metrics refer to the Windows Server Failover Clustering quorum state.
1431
+ labels: []
1432
+ metrics:
1433
+ - name: mssql.ag_cluster_quorum_state
1434
+ description: WSFC Cluster Quorum State
1435
+ unit: state
1436
+ chart_type: line
1437
+ dimensions:
1438
+ - name: normal
1439
+ - name: forced
1440
+ - name: unknown
1441
+ - name: WSFC cluster member
1442
+ description: These metrics refer to individual WSFC cluster members.
1443
+ labels:
1444
+ - name: cluster_member
1445
+ description: Cluster member name
1446
+ metrics:
1447
+ - name: mssql.ag_cluster_member_state
1448
+ description: WSFC Cluster Member State
1449
+ unit: state
1450
+ chart_type: line
1451
+ dimensions:
1452
+ - name: up
1453
+ - name: down
1454
+ - name: mssql.ag_cluster_member_quorum_votes
1455
+ description: WSFC Cluster Member Quorum Votes
1456
+ unit: votes
1457
+ chart_type: line
1458
+ dimensions:
1459
+ - name: votes
1460
+ - name: AG page repair
1461
+ description: These metrics refer to automatic page repair events per database in an Availability Group.
1462
+ labels:
1463
+ - name: database
1464
+ description: Database name
1465
+ metrics:
1466
+ - name: mssql.ag_page_repair
1467
+ description: AG Automatic Page Repair Events
1468
+ unit: repairs
1469
+ chart_type: line
1470
+ dimensions:
1471
+ - name: successful
1472
+ - name: failed
src/go/plugin/go.d/collector/mssql/mssql_test.go
+363
@@ -4,8 +4,10 @@ package mssql
4
5
import (
6
"context"
7
+ "fmt"
8
"testing"
9
10
+ "github.com/DATA-DOG/go-sqlmock"
11
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth"
12
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/cloudauth/sqladapter"
13
"github.com/stretchr/testify/assert"
@@ -77,3 +79,364 @@ func TestCollector_Charts(t *testing.T) {
79
assert.NotNil(t, charts)
80
assert.NotEmpty(t, *charts)
81
}
82
+
83
+func TestParseMajorVersion(t *testing.T) {
84
+ tests := []struct {
85
+ version string
86
+ expected int
87
+ }{
88
+ {"16.0.4175.1", 16},
89
+ {"15.0.4198.2", 15},
90
+ {"14.0.3456.2", 14},
91
+ {"13.0.6300.2", 13},
92
+ {"12.0.6024.0", 12},
93
+ {"11.0.7001.0", 11},
94
+ {"", 0},
95
+ {"invalid", 0},
96
+ {"abc.def", 0},
97
+ }
98
+
99
+ for _, tt := range tests {
100
+ t.Run(tt.version, func(t *testing.T) {
101
+ assert.Equal(t, tt.expected, parseMajorVersion(tt.version))
102
+ })
103
+ }
104
+}
105
+
106
+func TestCleanAGName(t *testing.T) {
107
+ tests := []struct {
108
+ name string
109
+ expected string
110
+ }{
111
+ {"MyAG", "myag"},
112
+ {"My AG-1", "my_ag_1"},
113
+ {"SERVER\\INSTANCE", "server_instance"},
114
+ {"ag.with.dots", "ag_with_dots"},
115
+ {"simple", "simple"},
116
+ }
117
+
118
+ for _, tt := range tests {
119
+ t.Run(tt.name, func(t *testing.T) {
120
+ assert.Equal(t, tt.expected, cleanAGName(tt.name))
121
+ })
122
+ }
123
+}
124
+
125
+func TestCleanAGReplicaName(t *testing.T) {
126
+ assert.Equal(t, "myag_server1", cleanAGReplicaName("MyAG", "SERVER1"))
127
+ assert.Equal(t, "prod_ag_node1_instance", cleanAGReplicaName("Prod-AG", "Node1.Instance"))
128
+}
129
+
130
+func TestCleanAGDatabaseReplicaName(t *testing.T) {
131
+ assert.Equal(t, "myag_server1_testdb", cleanAGDatabaseReplicaName("MyAG", "SERVER1", "TestDB"))
132
+}
133
+
134
+func TestAGDatabaseReplicaQuery(t *testing.T) {
135
+ assert.Equal(t, queryAGDatabaseReplicasPre16, agDatabaseReplicaQuery(11))
136
+ assert.Equal(t, queryAGDatabaseReplicasPre16, agDatabaseReplicaQuery(12))
137
+ assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(13))
138
+ assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(14))
139
+ assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(15))
140
+ assert.Equal(t, queryAGDatabaseReplicas16, agDatabaseReplicaQuery(16))
141
+}
142
+
143
+func TestCollector_Collect(t *testing.T) {
144
+ tests := map[string]struct {
145
+ majorVersion int // 0 means default (16)
146
+ prepareMock func(mock sqlmock.Sqlmock)
147
+ collectFn func(c *Collector, mx map[string]int64) error
148
+ wantErr bool
149
+ wantMetrics map[string]int64
150
+ notWantMetrics []string
151
+ checkCollector func(t *testing.T, c *Collector)
152
+ }{
153
+ "ag health: success": {
154
+ prepareMock: func(mock sqlmock.Sqlmock) {
155
+ mock.ExpectQuery(queryAGHealth).WillReturnRows(
156
+ sqlmock.NewRows([]string{"ag_name", "synchronization_health", "primary_recovery_health", "secondary_recovery_health"}).
157
+ AddRow("TestAG", int64(2), int64(1), int64(-1)),
158
+ )
159
+ },
160
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGHealth(mx) },
161
+ wantMetrics: map[string]int64{
162
+ "ag_testag_sync_health_not_healthy": 0,
163
+ "ag_testag_sync_health_partially_healthy": 0,
164
+ "ag_testag_sync_health_healthy": 1,
165
+ "ag_testag_primary_recovery_online": 1,
166
+ "ag_testag_primary_recovery_in_progress": 0,
167
+ "ag_testag_secondary_recovery_online": 0,
168
+ "ag_testag_secondary_recovery_in_progress": 0,
169
+ },
170
+ checkCollector: func(t *testing.T, c *Collector) {
171
+ assert.True(t, c.seenAGs["TestAG"])
172
+ },
173
+ },
174
+ "ag health: query error": {
175
+ prepareMock: func(mock sqlmock.Sqlmock) {
176
+ mock.ExpectQuery(queryAGHealth).WillReturnError(fmt.Errorf("connection lost"))
177
+ },
178
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGHealth(mx) },
179
+ wantErr: true,
180
+ },
181
+ "ag replica states: two replicas": {
182
+ prepareMock: func(mock sqlmock.Sqlmock) {
183
+ mock.ExpectQuery(queryAGReplicaStates).WillReturnRows(
184
+ sqlmock.NewRows([]string{"ag_name", "replica_server_name", "availability_mode", "failover_mode", "role", "connected_state", "synchronization_health"}).
185
+ AddRow("TestAG", "SERVER1", "synchronous_commit", "automatic", int64(1), int64(1), int64(2)).
186
+ AddRow("TestAG", "SERVER2", "asynchronous_commit", "manual", int64(2), int64(1), int64(2)),
187
+ )
188
+ },
189
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGReplicaStates(mx) },
190
+ wantMetrics: map[string]int64{
191
+ "ag_replica_testag_server1_role_primary": 1,
192
+ "ag_replica_testag_server1_role_secondary": 0,
193
+ "ag_replica_testag_server1_connected": 1,
194
+ "ag_replica_testag_server1_sync_health_healthy": 1,
195
+ "ag_replica_testag_server2_role_primary": 0,
196
+ "ag_replica_testag_server2_role_secondary": 1,
197
+ "ag_replica_testag_server2_connected": 1,
198
+ },
199
+ checkCollector: func(t *testing.T, c *Collector) {
200
+ assert.True(t, c.seenAGReplicas["TestAG_SERVER1"])
201
+ assert.True(t, c.seenAGReplicas["TestAG_SERVER2"])
202
+ },
203
+ },
204
+ "ag database replicas: 2016+ with secondary lag": {
205
+ prepareMock: func(mock sqlmock.Sqlmock) {
206
+ mock.ExpectQuery(queryAGDatabaseReplicas16).WillReturnRows(
207
+ sqlmock.NewRows([]string{
208
+ "ag_name", "replica_server_name", "database_name",
209
+ "synchronization_state", "is_suspended",
210
+ "log_send_queue_size", "log_send_rate",
211
+ "redo_queue_size", "redo_rate", "filestream_send_rate",
212
+ "secondary_lag_seconds",
213
+ }).
214
+ AddRow("TestAG", "SERVER1", "MyDB",
215
+ int64(2), int64(0),
216
+ int64(1024), int64(2048),
217
+ int64(512), int64(4096), int64(0),
218
+ int64(5)),
219
+ )
220
+ },
221
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGDatabaseReplicas(mx) },
222
+ wantMetrics: map[string]int64{
223
+ "ag_db_testag_server1_mydb_sync_state_synchronized": 1,
224
+ "ag_db_testag_server1_mydb_sync_state_synchronizing": 0,
225
+ "ag_db_testag_server1_mydb_log_send_queue_size": 1024,
226
+ "ag_db_testag_server1_mydb_log_send_rate": 2048,
227
+ "ag_db_testag_server1_mydb_redo_queue_size": 512,
228
+ "ag_db_testag_server1_mydb_redo_rate": 4096,
229
+ "ag_db_testag_server1_mydb_suspended": 0,
230
+ "ag_db_testag_server1_mydb_not_suspended": 1,
231
+ "ag_db_testag_server1_mydb_secondary_lag_seconds": 5,
232
+ },
233
+ checkCollector: func(t *testing.T, c *Collector) {
234
+ assert.True(t, c.seenAGDatabaseReplicas["TestAG_SERVER1_MyDB"])
235
+ },
236
+ },
237
+ "ag database replicas: pre-2016 no secondary lag": {
238
+ majorVersion: 12,
239
+ prepareMock: func(mock sqlmock.Sqlmock) {
240
+ mock.ExpectQuery(queryAGDatabaseReplicasPre16).WillReturnRows(
241
+ sqlmock.NewRows([]string{
242
+ "ag_name", "replica_server_name", "database_name",
243
+ "synchronization_state", "is_suspended",
244
+ "log_send_queue_size", "log_send_rate",
245
+ "redo_queue_size", "redo_rate", "filestream_send_rate",
246
+ "secondary_lag_seconds",
247
+ }).
248
+ AddRow("TestAG", "SERVER1", "MyDB",
249
+ int64(2), int64(0),
250
+ int64(0), int64(0),
251
+ int64(0), int64(0), int64(0),
252
+ int64(-1)),
253
+ )
254
+ },
255
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGDatabaseReplicas(mx) },
256
+ notWantMetrics: []string{"ag_db_testag_server1_mydb_secondary_lag_seconds"},
257
+ },
258
+ "ag cluster: quorum normal": {
259
+ prepareMock: func(mock sqlmock.Sqlmock) {
260
+ mock.ExpectQuery(queryAGCluster).WillReturnRows(
261
+ sqlmock.NewRows([]string{"quorum_state"}).AddRow(int64(1)),
262
+ )
263
+ },
264
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGCluster(mx) },
265
+ wantMetrics: map[string]int64{
266
+ "ag_cluster_quorum_state_unknown": 0,
267
+ "ag_cluster_quorum_state_normal": 1,
268
+ "ag_cluster_quorum_state_forced": 0,
269
+ },
270
+ checkCollector: func(t *testing.T, c *Collector) {
271
+ assert.True(t, c.agClusterChartAdded)
272
+ },
273
+ },
274
+ "ag cluster: query error": {
275
+ prepareMock: func(mock sqlmock.Sqlmock) {
276
+ mock.ExpectQuery(queryAGCluster).WillReturnError(fmt.Errorf("WSFC not configured"))
277
+ },
278
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGCluster(mx) },
279
+ wantErr: true,
280
+ },
281
+ "ag cluster members: two nodes": {
282
+ prepareMock: func(mock sqlmock.Sqlmock) {
283
+ mock.ExpectQuery(queryAGClusterMembers).WillReturnRows(
284
+ sqlmock.NewRows([]string{"member_name", "member_state", "number_of_quorum_votes"}).
285
+ AddRow("NODE1", int64(1), int64(1)).
286
+ AddRow("NODE2", int64(0), int64(1)),
287
+ )
288
+ },
289
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGClusterMembers(mx) },
290
+ wantMetrics: map[string]int64{
291
+ "ag_cluster_member_node1_up": 1,
292
+ "ag_cluster_member_node1_down": 0,
293
+ "ag_cluster_member_node1_quorum_votes": 1,
294
+ "ag_cluster_member_node2_up": 0,
295
+ "ag_cluster_member_node2_down": 1,
296
+ "ag_cluster_member_node2_quorum_votes": 1,
297
+ },
298
+ checkCollector: func(t *testing.T, c *Collector) {
299
+ assert.True(t, c.seenAGClusterMembers["NODE1"])
300
+ assert.True(t, c.seenAGClusterMembers["NODE2"])
301
+ },
302
+ },
303
+ "ag failover readiness: ready and joined": {
304
+ prepareMock: func(mock sqlmock.Sqlmock) {
305
+ mock.ExpectQuery(queryAGFailoverReadiness).WillReturnRows(
306
+ sqlmock.NewRows([]string{"ag_name", "replica_server_name", "database_name", "is_failover_ready", "is_database_joined"}).
307
+ AddRow("TestAG", "SERVER1", "MyDB", int64(1), int64(1)),
308
+ )
309
+ },
310
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGFailoverReadiness(mx) },
311
+ wantMetrics: map[string]int64{
312
+ "ag_db_testag_server1_mydb_failover_ready": 1,
313
+ "ag_db_testag_server1_mydb_failover_not_ready": 0,
314
+ "ag_db_testag_server1_mydb_joined": 1,
315
+ "ag_db_testag_server1_mydb_not_joined": 0,
316
+ },
317
+ },
318
+ "ag auto page repair: success": {
319
+ prepareMock: func(mock sqlmock.Sqlmock) {
320
+ mock.ExpectQuery(queryAGAutoPageRepair).WillReturnRows(
321
+ sqlmock.NewRows([]string{"database_name", "successful_repairs", "failed_repairs"}).
322
+ AddRow("MyDB", int64(3), int64(1)),
323
+ )
324
+ },
325
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGAutoPageRepair(mx) },
326
+ wantMetrics: map[string]int64{
327
+ "ag_page_repair_mydb_successful": 3,
328
+ "ag_page_repair_mydb_failed": 1,
329
+ },
330
+ checkCollector: func(t *testing.T, c *Collector) {
331
+ assert.True(t, c.seenAGPageRepairDBs["MyDB"])
332
+ },
333
+ },
334
+ "ag auto page repair: empty db name skipped": {
335
+ prepareMock: func(mock sqlmock.Sqlmock) {
336
+ mock.ExpectQuery(queryAGAutoPageRepair).WillReturnRows(
337
+ sqlmock.NewRows([]string{"database_name", "successful_repairs", "failed_repairs"}).
338
+ AddRow("", int64(1), int64(0)),
339
+ )
340
+ },
341
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGAutoPageRepair(mx) },
342
+ wantMetrics: map[string]int64{},
343
+ },
344
+ "ag threads: success": {
345
+ prepareMock: func(mock sqlmock.Sqlmock) {
346
+ mock.ExpectQuery(queryAGThreads).WillReturnRows(
347
+ sqlmock.NewRows([]string{"ag_name", "num_capture_threads", "num_redo_threads", "num_parallel_redo_threads"}).
348
+ AddRow("TestAG", int64(2), int64(4), int64(8)),
349
+ )
350
+ },
351
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAGThreads(mx) },
352
+ wantMetrics: map[string]int64{
353
+ "ag_testag_capture_threads": 2,
354
+ "ag_testag_redo_threads": 4,
355
+ "ag_testag_parallel_redo_threads": 8,
356
+ },
357
+ },
358
+ "ag pipeline: health error stops collection": {
359
+ prepareMock: func(mock sqlmock.Sqlmock) {
360
+ mock.ExpectQuery(queryAGHealth).WillReturnError(fmt.Errorf("access denied"))
361
+ },
362
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAvailabilityGroups(mx) },
363
+ wantErr: true,
364
+ },
365
+ "ag pipeline: replica states error stops collection": {
366
+ prepareMock: func(mock sqlmock.Sqlmock) {
367
+ mock.ExpectQuery(queryAGHealth).WillReturnRows(
368
+ sqlmock.NewRows([]string{"ag_name", "synchronization_health", "primary_recovery_health", "secondary_recovery_health"}).
369
+ AddRow("TestAG", int64(2), int64(1), int64(-1)),
370
+ )
371
+ mock.ExpectQuery(queryAGReplicaStates).WillReturnError(fmt.Errorf("timeout"))
372
+ },
373
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAvailabilityGroups(mx) },
374
+ wantErr: true,
375
+ },
376
+ "ag pipeline: cluster error is non-fatal": {
377
+ majorVersion: 14, // < 15, so AG threads query is skipped
378
+ prepareMock: func(mock sqlmock.Sqlmock) {
379
+ mock.ExpectQuery(queryAGHealth).WillReturnRows(
380
+ sqlmock.NewRows([]string{"ag_name", "synchronization_health", "primary_recovery_health", "secondary_recovery_health"}).
381
+ AddRow("TestAG", int64(2), int64(1), int64(-1)),
382
+ )
383
+ mock.ExpectQuery(queryAGReplicaStates).WillReturnRows(
384
+ sqlmock.NewRows([]string{"ag_name", "replica_server_name", "availability_mode", "failover_mode", "role", "connected_state", "synchronization_health"}).
385
+ AddRow("TestAG", "SERVER1", "synchronous_commit", "automatic", int64(1), int64(1), int64(2)),
386
+ )
387
+ // majorVersion=14 >= 13, so queryAGDatabaseReplicas16 is selected
388
+ mock.ExpectQuery(queryAGDatabaseReplicas16).WillReturnRows(
389
+ sqlmock.NewRows([]string{
390
+ "ag_name", "replica_server_name", "database_name",
391
+ "synchronization_state", "is_suspended",
392
+ "log_send_queue_size", "log_send_rate",
393
+ "redo_queue_size", "redo_rate", "filestream_send_rate",
394
+ "secondary_lag_seconds",
395
+ }),
396
+ )
397
+ mock.ExpectQuery(queryAGCluster).WillReturnError(fmt.Errorf("WSFC not available"))
398
+ mock.ExpectQuery(queryAGClusterMembers).WillReturnError(fmt.Errorf("not available"))
399
+ mock.ExpectQuery(queryAGFailoverReadiness).WillReturnError(fmt.Errorf("not available"))
400
+ mock.ExpectQuery(queryAGAutoPageRepair).WillReturnError(fmt.Errorf("not available"))
401
+ },
402
+ collectFn: func(c *Collector, mx map[string]int64) error { return c.collectAvailabilityGroups(mx) },
403
+ },
404
+ }
405
+
406
+ for name, tc := range tests {
407
+ t.Run(name, func(t *testing.T) {
408
+ db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
409
+ require.NoError(t, err)
410
+ defer func() { _ = db.Close() }()
411
+
412
+ c := New()
413
+ c.db = db
414
+ c.majorVersion = 16
415
+ if tc.majorVersion != 0 {
416
+ c.majorVersion = tc.majorVersion
417
+ }
418
+
419
+ tc.prepareMock(mock)
420
+
421
+ mx := make(map[string]int64)
422
+ err = tc.collectFn(c, mx)
423
+
424
+ if tc.wantErr {
425
+ assert.Error(t, err)
426
+ } else {
427
+ assert.NoError(t, err)
428
+ }
429
+ for key, want := range tc.wantMetrics {
430
+ assert.Equalf(t, want, mx[key], "metric %s", key)
431
+ }
432
+ for _, key := range tc.notWantMetrics {
433
+ _, ok := mx[key]
434
+ assert.Falsef(t, ok, "metric %s should not be present", key)
435
+ }
436
+ if tc.checkCollector != nil {
437
+ tc.checkCollector(t, c)
438
+ }
439
+ assert.NoError(t, mock.ExpectationsWereMet())
440
+ })
441
+ }
442
+}
src/go/plugin/go.d/collector/mssql/queries.go
+129
@@ -377,6 +377,135 @@ WHERE object_name LIKE '%Databases%'
377
AND instance_name NOT IN ('_Total', 'mssqlsystemresource');
378
`
379
380
+// queryHadrEnabled checks if Always On Availability Groups is enabled
381
+const queryHadrEnabled = `
382
+SELECT CAST(SERVERPROPERTY('IsHadrEnabled') AS int);
383
+`
384
+
385
+// queryAGHealth gets AG-level health rollup
386
+const queryAGHealth = `
387
+SELECT
388
+ ag.name AS ag_name,
389
+ ISNULL(ags.synchronization_health, -1) AS synchronization_health,
390
+ ISNULL(ags.primary_recovery_health, -1) AS primary_recovery_health,
391
+ ISNULL(ags.secondary_recovery_health, -1) AS secondary_recovery_health
392
+FROM sys.availability_groups AS ag
393
+INNER JOIN sys.dm_hadr_availability_group_states AS ags
394
+ ON ag.group_id = ags.group_id;
395
+`
396
+
397
+// queryAGReplicaStates gets per-replica role, connectivity, and sync health
398
+const queryAGReplicaStates = `
399
+SELECT
400
+ ag.name AS ag_name,
401
+ ar.replica_server_name,
402
+ LOWER(ar.availability_mode_desc) AS availability_mode,
403
+ LOWER(ar.failover_mode_desc) AS failover_mode,
404
+ ISNULL(ars.role, -1) AS role,
405
+ ISNULL(ars.connected_state, -1) AS connected_state,
406
+ ISNULL(ars.synchronization_health, -1) AS synchronization_health
407
+FROM sys.availability_replicas AS ar
408
+INNER JOIN sys.availability_groups AS ag
409
+ ON ar.group_id = ag.group_id
410
+INNER JOIN sys.dm_hadr_availability_replica_states AS ars
411
+ ON ar.replica_id = ars.replica_id;
412
+`
413
+
414
+// queryAGDatabaseReplicasPre16 gets per-database replica metrics (SQL Server 2012-2014)
415
+// No secondary_lag_seconds
416
+const queryAGDatabaseReplicasPre16 = `
417
+SELECT
418
+ ag.name AS ag_name,
419
+ ar.replica_server_name,
420
+ DB_NAME(drs.database_id) AS database_name,
421
+ ISNULL(drs.synchronization_state, -1) AS synchronization_state,
422
+ CAST(ISNULL(drs.is_suspended, 0) AS int) AS is_suspended,
423
+ ISNULL(drs.log_send_queue_size, 0) * 1024 AS log_send_queue_size,
424
+ ISNULL(drs.log_send_rate, 0) * 1024 AS log_send_rate,
425
+ ISNULL(drs.redo_queue_size, 0) * 1024 AS redo_queue_size,
426
+ ISNULL(drs.redo_rate, 0) * 1024 AS redo_rate,
427
+ ISNULL(drs.filestream_send_rate, 0) * 1024 AS filestream_send_rate,
428
+ -1 AS secondary_lag_seconds
429
+FROM sys.dm_hadr_database_replica_states AS drs
430
+INNER JOIN sys.availability_replicas AS ar
431
+ ON drs.replica_id = ar.replica_id
432
+INNER JOIN sys.availability_groups AS ag
433
+ ON drs.group_id = ag.group_id;
434
+`
435
+
436
+// queryAGDatabaseReplicas16 gets per-database replica metrics (SQL Server 2016+)
437
+// Adds secondary_lag_seconds
438
+const queryAGDatabaseReplicas16 = `
439
+SELECT
440
+ ag.name AS ag_name,
441
+ ar.replica_server_name,
442
+ DB_NAME(drs.database_id) AS database_name,
443
+ ISNULL(drs.synchronization_state, -1) AS synchronization_state,
444
+ CAST(ISNULL(drs.is_suspended, 0) AS int) AS is_suspended,
445
+ ISNULL(drs.log_send_queue_size, 0) * 1024 AS log_send_queue_size,
446
+ ISNULL(drs.log_send_rate, 0) * 1024 AS log_send_rate,
447
+ ISNULL(drs.redo_queue_size, 0) * 1024 AS redo_queue_size,
448
+ ISNULL(drs.redo_rate, 0) * 1024 AS redo_rate,
449
+ ISNULL(drs.filestream_send_rate, 0) * 1024 AS filestream_send_rate,
450
+ ISNULL(drs.secondary_lag_seconds, -1) AS secondary_lag_seconds
451
+FROM sys.dm_hadr_database_replica_states AS drs
452
+INNER JOIN sys.availability_replicas AS ar
453
+ ON drs.replica_id = ar.replica_id
454
+INNER JOIN sys.availability_groups AS ag
455
+ ON drs.group_id = ag.group_id;
456
+`
457
+
458
+// queryAGCluster gets WSFC cluster quorum state
459
+const queryAGCluster = `
460
+SELECT
461
+ quorum_state
462
+FROM sys.dm_hadr_cluster;
463
+`
464
+
465
+// queryAGClusterMembers gets WSFC cluster member states
466
+const queryAGClusterMembers = `
467
+SELECT
468
+ member_name,
469
+ member_state,
470
+ number_of_quorum_votes
471
+FROM sys.dm_hadr_cluster_members;
472
+`
473
+
474
+// queryAGFailoverReadiness gets per-database failover readiness
475
+const queryAGFailoverReadiness = `
476
+SELECT
477
+ ag.name AS ag_name,
478
+ ar.replica_server_name,
479
+ dcs.database_name,
480
+ CAST(ISNULL(dcs.is_failover_ready, 0) AS int) AS is_failover_ready,
481
+ CAST(ISNULL(dcs.is_database_joined, 0) AS int) AS is_database_joined
482
+FROM sys.dm_hadr_database_replica_cluster_states AS dcs
483
+INNER JOIN sys.availability_replicas AS ar
484
+ ON dcs.replica_id = ar.replica_id
485
+INNER JOIN sys.availability_groups AS ag
486
+ ON ar.group_id = ag.group_id;
487
+`
488
+
489
+// queryAGAutoPageRepair gets page repair event counts per database
490
+const queryAGAutoPageRepair = `
491
+SELECT
492
+ DB_NAME(database_id) AS database_name,
493
+ SUM(CASE WHEN page_status = 4 THEN 1 ELSE 0 END) AS successful_repairs,
494
+ SUM(CASE WHEN page_status = 5 THEN 1 ELSE 0 END) AS failed_repairs
495
+FROM sys.dm_hadr_auto_page_repair
496
+GROUP BY database_id;
497
+`
498
+
499
+// queryAGThreads gets AG thread usage (SQL Server 2019+ only)
500
+const queryAGThreads = `
501
+SELECT
502
+ name AS ag_name,
503
+ num_capture_threads,
504
+ num_redo_threads,
505
+ num_parallel_redo_threads
506
+FROM sys.dm_hadr_ag_threads;
507
+`
508
+
509
// waitTypeCategories maps wait types to their categories
510
var waitTypeCategories = map[string]string{
511
"ASYNC_IO_COMPLETION": "Other Disk IO",