Availability Group health collection in Lite + Darling (#991) - #1688
Merged
Conversation
Two new shared collectors, both server-scope, both zero-cost on a server without Always On: - ag_replica_states (replica grain): role, operational/connected state, recovery and synchronization health, availability and failover mode, endpoint URL. - ag_database_replica_states (database grain): synchronization state, log send + redo queue sizes, send + redo rates, secondary lag, suspension state/reason, and both LSNs. Metric surface follows Hannah Vernon's SqlServerAgMonitor (MIT), attributed in both collector headers. The is_distributed = 0 filter keeps distributed-AG container rows out while member AGs still appear. Runs everywhere except Azure SQL DB (no AG surface there). An AG-less on-prem server is deliberately NOT gated off - it collects and stores zero rows, so enabling Always On later starts producing data with no configuration change. Three traps, each pinned by test: - The queues, rates and lag are instantaneous gauges, not counters, so neither collector touches the delta framework and the compose measures are Gauge (avg/min/max, never SUM). - last_hardened_lsn / last_commit_lsn are numeric(25,0) - wider than BIGINT - so they convert server-side and store as text. - Every column is read null-tolerantly: under WSFC quorum loss sys.availability_replicas serves only cached metadata and endpoint_url is documented NULL. Per-minute cadence, 30-day retention, in all three presets in both apps. Custom Views v2 gets five Gauge measures on the database grain under an Availability Groups category, sliceable by AG / database / replica plus the universal server dimension. Darling store migration V34 (with the matching viewer schema-probe sentinel and map arm, without which the connect-time gate would refuse a fully-migrated store); Lite's storage registers itself off the collector catalog. Collection only in this cut - no viewer tab, no failover / sync alerts - so both apps' viewer-coverage ratchets carry the two tables as tracked debt. That ratchet also gets a real fix: it text-scans the reader layer for a table name, so naming a collector table as a schema-probe sentinel made it read as "already covered". The scan now strips the probe's information_schema lines, which retroactively hardens the same pin for long_query_completions. Closes #991 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
enabled auto-merge
July 26, 2026 16:54
…collector # Conflicts: # CHANGELOG.md
The main README's Lite collector table now lists ag_replica_states and ag_database_replica_states, and its count goes 36 -> 38 (verified against CollectorCatalog.All: both are 38). Darling's README said 32 collector definitions, already stale before this change; corrected to 38. Its example store-migrate log line still showed schema v20, now v34. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
added a commit
that referenced
this pull request
Jul 26, 2026
Lite.Tests/CrossAppMcpToolInventoryPinTests scans BOTH apps' Mcp/*.cs and asserts Darling's tool set is a superset of Lite's, so a new Darling tool with no Lite twin fails the CI build job. A Darling-only local build never runs that project, so this would only have surfaced in CI. Flagged as a real "not ported yet", unlike the Custom Views / alert-tuning / onboarding entries that are Darling-only by architecture: the AG collectors landed in BOTH apps (#1688), so Lite already has the two tables in its local DuckDB and a twin is a DuckDB reader over the same banding rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata
added a commit
that referenced
this pull request
Jul 26, 2026
One Added entry for the Availability Group alert family, three Fixed entries for the adjacent drift (the resolution-suffix misclassification in both apps, the MCP alert-settings under-report, and the parallel-collection test flake), plus the link-ref. Also corrects the clause #1688 left behind saying the failover and sync-fell-behind alerts were still follow-ups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 26, 2026
Merged
pull Bot
pushed a commit
to ehtick/PerformanceMonitor
that referenced
this pull request
Jul 29, 2026
…rap filterable, pin V34 for real Review follow-ups to erikdarlingdata#1688. The grant is the finding that matters in the field. Both AG collectors join the sys.availability_groups / sys.availability_replicas CATALOG VIEWS to the sys.dm_hadr_* DMVs. The DMVs are covered by the VIEW SERVER STATE the product documents, but the catalog views require VIEW ANY DEFINITION - and catalog views enforce that by HIDING ROWS, not by raising an error. So on a real AG cluster a login with only the documented grant returned zero rows, which is byte-identical to what an AG-less server returns: the collectors would look healthy forever while collecting nothing, with no error to notice. Documented in both READMEs (grant script + the Darling permission table's If-missing column) and in both collector headers, together with the fingerprint that identifies it if it is ever worth auto-detecting: the DMV returning rows while the catalog view returns none is unambiguous, and SERVERPROPERTY('IsHadrEnabled') is readable by every login. Verified against MS Learn's own AG monitoring page, which states the split directly. The documented lag trap is now actionable rather than merely documented. secondary_lag_seconds reads 0 (not NULL) while data movement is suspended, so a suspended replica charts as healthy - but nothing exposed the suspension state to a panel, making the misread unavoidable. synchronization_state_desc and suspend_reason_desc are now compose dimensions, so a lag panel can filter suspended replicas out or group by suspend reason. is_suspended itself cannot be a dimension: the compiler binds filter values as text, which would not match a boolean column. And V34 is now genuinely pinned. Its test asserted only that each column NAME appeared somewhere in the DDL, so a V34 with columns reordered, is_local typed text, or a spurious NOT NULL passed - while a comment claimed the shape was pinned elsewhere. It now compares the whole generated CREATE TABLE via the existing Migrations_JobHistoryAndAgentStatus_MatchGeneratedFreshShape idiom; detection power confirmed by planting is_local text and watching it go red. That test's rationale was also wrong and is corrected: it blamed a "positional binary COPY", but PgCollectorRowWriter.CopyCommandFor emits a named column list, so Postgres binds by name. Names and types are the real hazard there; the positional appender is Lite's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull Bot
pushed a commit
to ehtick/PerformanceMonitor
that referenced
this pull request
Jul 29, 2026
…erfmon counters (erikdarlingdata#991) ag_database_replica_states gains six appended columns: - last_commit_time / last_hardened_time / last_redone_time / last_received_time, the four DMV timestamps the reference query skips. Unlike secondary_lag_seconds these are directly comparable across replicas, which the primary-vs-secondary commit-time lag math needs. - est_redo_completion_time_min / est_send_drain_time_min, computed server-side as queue / rate / 60 with both guards the raw expression needs: `* 1.0` stops BIGINT/BIGINT integer division flooring a sub-minute drain to zero, NULLIF(rate, 0) stops the divide-by-zero an idle or suspended replica raises (which fails the whole cycle, not one column). NULL means "no drain rate" and is never coerced to 0, which would read as "drains instantly". The estimates are computed per row at the sample's own instant rather than composed later, because a ratio of two window aggregates is not the average of the per-sample ratios and the two diverge worst exactly when rates swing. Both are Gauge compose measures, duration family, minutes. PerfmonStatsCollector's whitelist gains Transaction Delay and Mirrored Write Transactions/sec - the primary side of commit latency, their ratio being the average delay per mirrored transaction. Zero new schema. Both verified live on SQL2022: they exist with no AGs configured, sit on SQLServer:Database Replica, and occur exactly once server-wide, so the counter_name-only filter cannot collide. Corrects a doc claim shipped in erikdarlingdata#1688. It restated MS Learn's assertion that secondary_lag_seconds reads 0 while data movement is suspended; a Docker AG fixture measured the inverse on SQL Server 2022 in a CLUSTER_TYPE = NONE group - 0 while movement is ACTIVE and caught up, accruing monotonically once suspended (0 to 62s across a 60s SUSPEND_FROM_USER, back to 0 on resume). A suspended replica does not hide as zero lag. Also documents two further measured quirks: log_send_queue_size goes NULL while suspended while redo_queue_size freezes at its last value, and collecting from a secondary yields a one-row self-view because sys.dm_hadr_* carries only the local replica there. Store migration V36 appends the columns additively instead of widening V34, because V34's CREATE TABLE IF NOT EXISTS is a no-op on an already-migrated store - editing it would leave every existing store six columns short while fresh installs got them. The schema pin now reconstructs the current shape from V34 + V36 and compares it to the generator; both failure modes confirmed by planted defects. Version 36 because 35 is claimed by the concurrent AG-alerts work. THIRD_PARTY_NOTICES.md now credits Hannah Vernon's SqlServerAgMonitor alongside the existing collector-header attribution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull Bot
pushed a commit
to ehtick/PerformanceMonitor
that referenced
this pull request
Jul 29, 2026
…ver every template (erikdarlingdata#991) The fifth Custom Views v2 seed, built on the AG measures from erikdarlingdata#1688/erikdarlingdata#1695. Five panels in diagnostic order: 1. Secondary lag over time, grouped by replica. 2. Dual-axis line: log send rate vs redo rate on their own axes - the panel that answers which side is the bottleneck. 3. Stacked redo-queue series by database. 4. Stat tile: worst estimated redo drain. 5. Top-10 bar of send-queue backlog by database. The prose between panels carries the two things that make the numbers readable: point the view at the PRIMARY (sys.dm_hadr_* on a secondary only carries the local replica, so a secondary-scoped view is a one-row self-view), and a blank drain estimate means no drain rate - idle, caught up, or suspended - not that it drains instantly. Adds the two template helpers the existing seeds never needed: overlay support on tsPanel (dual-axis, legal only on an ungrouped line/area) and a scalar statPanel. This is the first seed exercising dual-axis, stacked and stat modes end to end. The brief asked for span 2 on the lag panel; notebooks are single-column documents and the renderer strips span, so it is omitted rather than stored as a field nothing honors. Also closes a hole in the templates drift guard. It hand-mirrors each template's panels so a MeasureCatalog change that breaks a seed fails in CI, but nothing checked the mirror COVERED every template - a sixth template added without a mirror entry would have gone silently unvalidated until it 400'd in a browser. It now reads the template keys out of notebook.js and requires the mirror to match exactly. Verified by renaming a key and watching it go red, and the new mirror was checked against the real module output by executing notebook.js under node and diffing the emitted panel JSON. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull Bot
pushed a commit
to ehtick/PerformanceMonitor
that referenced
this pull request
Jul 29, 2026
…arlingdata#1696) Lite has collected both AG grains since erikdarlingdata#1688 but could not tell you a replica had failed over, disconnected, fallen behind, or had data movement suspended - only Darling could. This closes that, and does it by making the RULES shared rather than by writing a second implementation that would drift. PerformanceMonitor.Common.AgAlertPolicy is the new single definition, on the ConnectionAlertPolicy pattern: the readings, the four metric-name consts, and every pure decision (IsFailover, DecideConnection, DecideSuspension, JudgeSync) live there; each app owns only its own edge STATE and its own delivery. The metric names are webhook automation keys, so an operator's webhook keyed on "AG Failover" now matches whether the alert came from Lite or from Darling. Darling's evaluator is refactored onto it with no behavior change - its full suite passes untouched, which is the point of doing the lift as its own step. Lite side: - LocalDataService reads the latest snapshot of each grain from DuckDB, each gated on its OWN collection time, because the two AG collectors are scheduled independently and a stale database-grain snapshot must not be vouched for by a healthy replica-grain one. - AgAlertEvaluator holds the per-grain edge state and returns alert descriptors. It is WPF-free so it pins directly; MainWindow does the sending through the same mute-check + TrySendAlertEmailAsync path every other Lite alert uses, so AG alerts inherit muting, silencing, the combined history row, and the email/webhook fan-out for free. - Three settings mirroring Darling's V35 knobs (notify_ag_health default on, ag_lag_alert_seconds 300, ag_redo_queue_alert_kb 0 = off), clamped to the same ranges on load AND on save so the stored value and the effective value cannot disagree, with the twin controls in Lite's Settings window. - Forget on server removal, or a remove-then-re-add would compare the new first sighting against the OLD role and page a phantom failover. The storage name hashes deterministically, so a re-added server really does get the same id. Every rule the earlier AG work paid for is carried over rather than reinvented: first sighting is a silent baseline, NULL is never a transition, and a SUSPENDED row may raise an alarm but may never clear one - including the case that a suspended secondary drifting past the threshold still fires, which is the single most common way a secondary falls behind. The Lite collector-coverage ratchet went red on this, exactly as designed: both AG tables were allow-listed as collect-only, adding a reader made them "actually read", and the entries had to be drained. The allow-list is now EMPTY - left in place rather than deleted, so re-adding an entry stays a deliberate visible act. Twin AgAlertPolicyTests in Lite.Tests and Darling.Tests (duplicated verbatim, the ConnectionAlertPolicyTests arrangement) pin the shared matrix from both suites; AgAlertEvaluatorTests pins Lite's state, cooldown, recovery and Forget, including that server 42 and server 4242 cannot collide on the key prefix. Suites green: Lite 1534, Darling 3286, Dashboard 768. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #991, and the AG item of #1606 (the one real coverage gap the Datadog DBM comparison turned up).
Scope is collection only, per the design: two tables, both apps, compose measures. No viewer tab and no alerts in this cut.
What lands
Two shared collectors in
PerformanceMonitor.Collectors, both server-scope, one query per sweep:ag_replica_statessys.availability_replicas+sys.availability_groups+sys.dm_hadr_availability_replica_statesag_database_replica_statessys.dm_hadr_database_replica_states+ the two catalog views +sys.databasesThe metric surface follows Hannah Vernon's SqlServerAgMonitor (MIT, (c) 2026 Hannah Vernon), attributed in both collector header comments.
WHERE COALESCE(ag.is_distributed, 0) = 0keeps distributed-AG container rows out while member AGs still appear; DAG member drill-through needs a connection per member and is out of scope.Two collector classes rather than one, because the catalog idiom is strictly one table per collector (
TargetTableis a single string,PayloadColumnsa single list). Git history backs that reading:latch_stats/spinlock_statsandcpu_scheduler_stats/plan_cache_statseach shipped as a related pair of classes in one commit.Gating
AppliesTois!target.IsAzureSqlDb-- the same idiomserver_configandtrace_flagsuse -- which gives on-prem + Managed Instance + RDS and excludes Azure SQL DB. An AG-less server is deliberately not gated off: it collects and stores zero rows, so turning Always On on later starts producing data with no configuration change.One thing worth flagging: MS Learn stamps
sys.dm_hadr_database_replica_statesas SQL Server + Managed Instance, but stamps the two catalog views as SQL Server only. I followed the instructed gate (MI included) since MI does expose the AG objects; if an MI target ever errors on the catalog views, that gate is the place to narrow.Three traps, each pinned by test
Gauge(avg/min/max, never SUM).WritePayload_EmitsPayloadOrder_AndTakesNoDeltasasserts the delta calculator is never called.last_hardened_lsn/last_commit_lsnarenumeric(25, 0)-- wider than BIGINT -- so they convert server-side and store as text. Storing them numerically would silently overflow. (Onlylast_commit_lsnis a real LSN; the other is a zero-padded log-block id, so neither is safe for arithmetic. Deriving a byte distance is analysis, left to a reader.)sys.availability_replicasserves only locally cached metadata, andendpoint_urlis documented NULL in that state. BothToleratesNullstests cover an all-NULL row.A fourth worth recording for whoever builds the alerts:
secondary_lag_secondsreads 0, not NULL, while data movement is suspended, so a suspended replica charts as zero lag.is_suspendedis collected alongside it precisely so a reader can tell the two apart. Documented in the collector.Registrations
CollectorCatalog.All+CollectorScheduleDefaults(1 min / 30 days, default ON, in all three cadence presets in both apps).RemoteCollectorService.AvailabilityGroups.cspartial,ScheduleManagerdefaults, bundledcollection_schedule.json. The "three registrations" (Schema.cs / DuckDbInitializer / ArchiveService) no longer exist as separate steps -- all three now enumerateCollectorCatalog.All, so catalog registration is the whole storage wiring. Verified againstlong_query_completions, the newest collector, which touches none of those files.DarlingWorkerdispatch, store migration V34 +StorageVersionbump, viewer schedule presets.The V34 migration needed a viewer change too
ViewerDataService.MapProbedSchemaVersionis a newest-first sentinel ladder, and the connect-time gate refuses a store belowRequiredStoreSchemaVersion. BumpingStorageVersionwithout adding a matching sentinel + arm would have made a fully-migrated V34 store probe as v33 and permanently refuse to open the viewer. Added both (table-existence sentinel on the database-grain table); the existing pinning test caught it, which is what it is there for.Compose
Five Gauge measures on
ag_database_replica_statesunder a newAvailability Groupscategory: send queue and redo queue (bytes family, native KB, default Max -- the worst backlog in the bucket is the signal), send rate and redo rate (bytes family, native KB, default Avg), and secondary lag (duration family, natives, default Max). Dimensions:ag_name,database_name,replica_server_name, plus the universalserver.The rates are KB/second but the catalog has no per-second unit family, so per the instructions they ride the bytes family -- correct under conversion, since kb/s to mb/s scales by the same factor -- with the per-second nature stated in the display name rather than implied by a unit the picker renders as a plain size.
The replica-grain table contributes no measures: it is all state strings with nothing numeric to aggregate.
A coverage-pin bug found and fixed along the way
Both apps' collector-coverage ratchets text-scan the viewer's reader layer for a table name. Naming a collector table as a schema-version probe sentinel (as V34 does, and as V29 already did for
long_query_completions) made that table read as "already covered" and silently exempted it from the ratchet. The Darling scan now strips the probe'sinformation_schemalines before matching -- those are DDL existence checks, never data reads, andinformation_schemaappears nowhere else in that layer. That retroactively hardens the pin forlong_query_completionstoo.Both tables are listed as tracked debt in both ratchets, with the follow-up named.
Tests
Extended every pin the change touches (catalog counts, golden DuckDB schema + indexes, Lite/Darling preset sets, migration ladder, PG generator counts, schema-probe map) and added:
Lite.Tests/AgCollectorDefinitionTests.cs-- 13 tests: verbatim query text for both, DAG filter, target gate across all four platforms, no per-database run, payload order + declared types, ReadAsync mapping, null tolerance, zero-rows-is-not-an-error, payload emission with no delta calls, catalog + schedule registration, and by-name gate agreement.DarlingComposeTests-- 4 new tests (all five measures Gauge/non-summable/right category, native units + default aggregates, the three dimensions, the lag panel compiling and grouping by replica) plus an InlineData row proving SUM on an AG gauge is rejected.Installer.Tests was not run (live-DB categories clobber real Agent jobs on SQL2022).
Live smoke -- SQL2022, no AGs
Ran both collector queries verbatim against
sql2022(SQL2022, 16.0.4255.1,IsHadrEnabled = 0). Both parse, execute, and return zero rows with the full expected column set -- the normal AG-less path, proving it is a clean empty read and not an error:The smoked SQL was diffed against the
QueryTextconstants in both collector sources and is byte-identical, so this exercised exactly what ships -- not a hand-retyped approximation.Follow-ups (deliberately not here)
An Availability Groups viewer tab, and the failover-detected / sync-fell-behind alerts. Both ratchets will go red when the tab ships, forcing the allow-list entries out.
🤖 Generated with Claude Code