@cryptotaxi247 / netdata-1 / commits / 2ef679ee9

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, &parallelRedoThreads); 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",