Alerting
DTM does not decide how you are notified. Whether an alert becomes an email, an SMS, a Teams message, a webhook, an incident ticket, or nothing at all is up to you. You set that up in your own alerting stack, and DTM supplies the signals and the rules. The deployment templates create no alerting.
There are two routes. Use the one your on-call already listens to; they can run side by side.
| Route | What you run | Rules | Coverage |
|---|---|---|---|
| A. Prometheus and Alertmanager | Prometheus scraping every node, and Alertmanager or Grafana alerting | dtm-alerts.yml |
Every rule, including DNS latency against the SLO and a whole-cluster outage |
| B. Azure Monitor | Azure Monitor metric alert rules, created by a Terraform, Bicep or ARM sample | The sample’s 33 rules per node | Every rule except three (a whole-cluster outage, a managed script check that is not running, and a private endpoint zone group the node cannot read), with a few others approximated; see what route B cannot alert on |
Route A: Prometheus and Alertmanager
Section titled “Route A: Prometheus and Alertmanager”-
Scrape every node. Add the scrape job from the observability page.
-
Load the rules. DTM’s Prometheus alert-rules file is
dtm-alerts.yml. Email support for the version that matches your release. Add it underrule_files:in your Prometheus configuration, and check it loads:Terminal window promtool check rules dtm-alerts.yml -
Set your cluster size.
DTMClusterUndersizedcompares each node’s view of the cluster with a node count markedEDIT ME, which assumes three nodes. Set it to your node count. -
Route the alerts. Each rule carries a
severitylabel,criticalorwarning. Route on it in Alertmanager (or Grafana alerting) to whatever receivers you use.
Verify: in Prometheus, Status, Rules lists the DTM rule groups with no errors, and Alerts shows them inactive on a healthy cluster. To prove the whole path pages someone, follow the fire drill in wire observability end to end.
Two rules map directly to the published DNS latency SLO. Both judge DTM’s own answers only: queries for zones the cluster owns, and override records. Forwarded queries are left out, because their time is mostly the upstream resolver’s.
DTMDNSLatencyAboveSLO(warning): p99 above 10 ms for 15 minutes.DTMDNSLatencyHigh(critical): p95 above 50 ms for 5 minutes.
Both need at least 100 such queries in 15 minutes, so a nearly idle node does not page on a handful of slow answers.
Route B: Azure Monitor
Section titled “Route B: Azure Monitor”Each DTM node can publish its own metrics to Azure Monitor, as custom metrics on its own virtual machine. The alerting samples create metric alert rules on them, one set per node, and attach every rule to an action group you already have.
Before you deploy
Section titled “Before you deploy”-
Turn on the Azure Monitor exporter on every node, and leave the target blank so each node publishes to its own VM. Per node:
Terminal window curl -sk -X PUT https://<node>:8443/api/v1/config/azure-monitor \-H "Authorization: Bearer $DTM_TOKEN" \-H "X-DTM-CSRF: 1" \-H "Content-Type: application/json" \-d '{"enabled": true}'You can also use the Azure Monitor card on the Settings page, or the
dtm_azure_monitor_configTerraform resource. The setting takes effect at the node’s next restart, so restart one node at a time and wait forcurl -sk https://<node>:8443/readyzto return 200 before the next.monitor_resource_idandmonitor_regionleft out means “this VM”, read from the Azure Instance Metadata Service.- Every series then carries a
nodedimension holding the node ID. - Do not send
"monitor_metrics_enabled": false: that is the logs-only setup, and it publishes no metrics. - If you also ship the query log to Log Analytics, include your
logs_ingestion_*settings in the same request, because a PUT replaces the whole record (see Ship the query log to Log Analytics).
-
Let each node publish to its own VM. The node’s identity needs the Monitoring Metrics Publisher role on its own VM. The samples grant it to the VM’s system-assigned identity unless you turn that off. If your nodes run on a user-assigned identity you brought yourself, turn the grant off and give that identity the role on each VM instead.
-
Have an action group. Use an existing one, or create one with the receivers you want. For example, an email-only group:
Terminal window az monitor action-group create \--resource-group rg-monitoring \--name dtm-oncall \--short-name dtmoncall \
Verify the metrics are arriving before you rely on any rule. GET /api/v1/config/azure-monitor on a node reports target_source: imds and that
node’s VM as effective_resource_id. Then:
az monitor metrics list-definitions \ --resource <node-vm-resource-id> --namespace DTM \ --query "[?name.value=='dtm_heartbeat'].name.value" -o tsvIt prints dtm_heartbeat once the node has published at least once.
Deploy the sample
Section titled “Deploy the sample”All three flavours create the same rules from the same inputs. Deploy into the resource group that holds your DTM node VMs.
| Flavour | File |
|---|---|
| Terraform | main.tf |
| Bicep | main.bicep |
| ARM template (JSON) | azuredeploy.json |
curl -O https://docs.hayami.io/samples/alerting/terraform/main.tfterraform initterraform apply \ -var 'resource_group_name=rg-dtm' \ -var 'action_group_id=<action-group-resource-id>' \ -var 'node_vm_names=["dtm-node-1","dtm-node-2","dtm-node-3"]'curl -O https://docs.hayami.io/samples/alerting/bicep/main.bicepaz deployment group create \ --resource-group rg-dtm \ --template-file main.bicep \ --parameters actionGroupId=<action-group-resource-id> \ nodeVmNames='["dtm-node-1","dtm-node-2","dtm-node-3"]'The ARM template takes the same parameters as the Bicep file; deploy it the same
way with --template-file azuredeploy.json.
| Input (Terraform / Bicep and ARM) | Required | Meaning |
|---|---|---|
resource_group_name / the deployment’s resource group |
yes | The resource group holding the node VMs. The rules are created there too. |
action_group_id / actionGroupId |
yes | Your existing action group. Every rule notifies it. |
node_vm_names / nodeVmNames |
yes | The node VMs. Each rule is created once per VM, scoped to it. |
expected_node_count / expectedNodeCount |
no | Defaults to the number of node VMs. |
expected_dns_listeners / expectedDnsListeners |
no | Defaults to 2 (UDP and TCP). Add one if you enabled DNS over TLS. DNS over HTTPS is not counted: it has its own dtm_dns_doh_listener_up gauge. When in doubt, read dtm_dns_listeners_total from a healthy node. |
dns_latency_p99_threshold_seconds / dnsLatencyP99ThresholdSeconds |
no | Defaults to 0.010 (10 ms, the SLO objective). The p99 latency of the node’s own answers, in seconds, above which dns-latency-above-slo fires. Bicep and ARM take it as a decimal string ('0.010') that nothing checks, so write seconds, not milliseconds. |
dns_latency_p95_threshold_seconds / dnsLatencyP95ThresholdSeconds |
no | Defaults to 0.050 (50 ms). The p95 latency, in seconds, above which dns-latency-high fires. A decimal string in Bicep and ARM, like the p99 one. |
grant_metrics_publisher / grantMetricsPublisher |
no | Defaults to on: grants each node’s identity Monitoring Metrics Publisher on its own VM. |
create_example_action_group / createExampleActionGroup |
no | Example only. Creates an email-only action group to try the rules with. Replace it with your own for real use. |
If your nodes are in more than one resource group, deploy once per group and set the expected node count to the whole cluster’s size.
The rules
Section titled “The rules”Thirty-three rules per node VM, so a three-node cluster gets 99 (changed in
v1.0.6: swap-stable-ip-unrestored is new). Each is named
<prefix>-<vm>-<rule>, for example dtm-dtm-node-1-heartbeat-missing.
Thresholds are those of dtm-alerts.yml. Critical rules are Azure severity 0
and warnings severity 2.
An Azure metric alert compares one metric with a fixed value: it cannot take a
rate, a ratio, a percentile, or the time since an event. So DTM computes those
itself and publishes each result as a metric of its own, named for what it
holds (for example dtm_health_check_drops_increase_5m, the increase over 5
minutes); see
how DTM publishes to Azure Monitor.
A Prometheus rule’s for: becomes the evaluation window, the smallest one Azure
allows that covers it, and “holds for the whole window” is Minimum (for
“above”) or Maximum (for “below”). So a rule does not fire earlier than its
Prometheus counterpart, except the three that count restarts with Total and
tls-certificate-expiring (see where route B differs).
| Rule | Metric | Aggregation | Condition | Window | Sev | Prometheus rule |
|---|---|---|---|---|---|---|
| heartbeat-missing | dtm_heartbeat |
Count | < 1 |
PT5M | 0 | DTMNodeDown (see the caution below) |
| cluster-undersized | dtm_cluster_nodes |
Maximum | < expected node count |
PT5M | 0 | DTMClusterUndersized |
| not-serving-dns | dtm_dns_listeners_alive |
Maximum | < expected DNS listeners |
PT5M | 0 | DTMNodeNotServingDNS (approximated) |
| stored-certificate-refused | dtm_tls_stored_pair_refused |
Minimum | >= 1 |
PT15M | 2 | DTMStoredCertificateRefused |
| tls-certificate-expiring | dtm_tls_certificate_expires_in_seconds |
Minimum | < 1814400 (21 days) |
PT6H | 2 | DTMTLSCertificateExpiringSoon |
| member-unreachable-day | dtm_replication_longest_unreachable_member_seconds |
Minimum | > 86400 |
PT15M | 2 | DTMMemberUnreachableLong |
| member-unreachable-week | dtm_replication_longest_unreachable_member_seconds |
Minimum | > 604800 |
PT15M | 0 | DTMMemberUnreachableWeek |
| member-fenced | dtm_replication_fenced_members |
Maximum | > 0 |
PT5M | 0 | DTMMemberFenced |
| stale-replica-blocked | dtm_replication_stale_replica_blocked |
Maximum | > 0 |
PT5M | 0 | DTMStaleReplicaBlocked |
| swap-stable-ip-unrestored | dtm_lifecycle_swap_stable_ip_unrestored |
Maximum | > 0 |
PT5M | 0 | DTMSwapStableIPUnrestored |
| security-advisory-critical | dtm_advisory_active, severity = critical |
Maximum | >= 1 |
PT5M | 0 | DTMSecurityAdvisoryCritical |
| security-advisory | dtm_advisory_active, severity not critical |
Maximum | >= 1 |
PT5M | 2 | DTMSecurityAdvisory |
| tag-registration-unclaimed | dtm_tag_registration_unclaimed_endpoints |
Minimum | > 0 |
PT30M | 2 | DTMTagRegistrationUnclaimed |
| management-group-access-revoked | dtm_region_discovery_membership_lost_access |
Minimum | > 0 |
PT1H | 2 | DTMManagementGroupAccessRevoked |
| region-discovery-incomplete | dtm_region_discovery_last_cycle_complete Maximum < 1 and dtm_region_discovery_last_run_timestamp_seconds Minimum > 0 |
PT6H | 2 | DTMBackgroundLoopIncomplete |
||
| tag-registration-incomplete | dtm_tag_registration_last_cycle_complete Maximum < 1 and dtm_tag_registration_last_run_timestamp_seconds Minimum > 0 |
PT6H | 2 | DTMBackgroundLoopIncomplete |
||
| data-disk-critical | dtm_storage_free_ratio |
Maximum | < 0.05 |
PT5M | 0 | DTMStorageDiskCritical |
| data-disk-low | dtm_storage_free_ratio |
Maximum | < 0.10 |
PT15M | 2 | DTMStorageDiskLow |
| restart-loop | dtm_process_starts |
Total | > 2 |
PT15M | 0 | DTMRestartLoop (approximated) |
| bbolt-stale-lock-loop | dtm_storage_bbolt_stale_lock_recovered_total |
Total | >= 2 |
PT30M | 2 | DTMBboltStaleLockLoop (approximated) |
| memory-self-restart | dtm_memory_self_restart_total |
Total | > 0 |
PT1H | 2 | DTMMemorySelfRestart (approximated) |
| health-check-drops | dtm_health_check_drops_increase_5m |
Minimum | > 0 |
PT15M | 2 | DTMHealthCheckDrops |
| query-error-rate | dtm_dns_query_error_ratio_5m |
Minimum | > 0.05 |
PT5M | 2 | DTMQueryErrorRate |
| dns-latency-above-slo | dtm_dns_authoritative_latency_p99_15m_seconds Minimum > the p99 threshold and dtm_dns_authoritative_queries_increase_15m Minimum >= 100 |
PT15M | 2 | DTMDNSLatencyAboveSLO |
||
| dns-latency-high | dtm_dns_authoritative_latency_p95_15m_seconds Minimum > the p95 threshold and dtm_dns_authoritative_queries_increase_15m Minimum >= 100 |
PT5M | 0 | DTMDNSLatencyHigh |
||
| bbolt-freelist-bloat | dtm_storage_bbolt_free_page_ratio |
Minimum | > 0.5 |
PT6H | 2 | DTMBboltFreelistBloat |
| replication-lag-high | dtm_replication_last_sync_age_seconds Minimum > 120 and dtm_cluster_nodes Minimum > 1 |
PT5M | 2 | DTMReplicationLagHigh |
||
| replication-queue-backlog | dtm_replication_broadcast_queue_depth Minimum >= 5000 and dtm_cluster_nodes Minimum > 1 |
PT15M | 2 | DTMReplicationQueueBacklog |
||
| region-discovery-stalled | dtm_region_discovery_last_run_age_seconds |
Minimum | > 10800 |
PT15M | 2 | DTMBackgroundLoopStalled |
| tag-registration-stalled | dtm_tag_registration_last_run_age_seconds |
Minimum | > 10800 |
PT15M | 2 | DTMBackgroundLoopStalled |
| region-discovery-failing | dtm_region_discovery_run_errors_increase_30m |
Minimum | > 0 |
PT1H | 2 | DTMBackgroundLoopFailing |
| tag-registration-failing | dtm_tag_registration_run_errors_increase_30m |
Minimum | > 0 |
PT1H | 2 | DTMBackgroundLoopFailing |
| azure-auth-denied | dtm_azure_auth_denials_increase_10m, split by backend |
Minimum | > 0 |
PT15M | 2 | DTMAzureAuthDenied |
Added in v1.0.6. swap-stable-ip-unrestored fires when a failed
stable-IP swap could not leave a running VM holding
the stable IP, so its clients may get no answer. Only the node that drove the
swap reports it. It reports what the swap recorded, not a live check of the
address: after fixing the address by hand, retry the swap to clear it.
Where route B differs
Section titled “Where route B differs”- restart-loop, bbolt-stale-lock-loop and memory-self-restart are tied to a
process restart, which ends any window a node keeps in memory, so they count
events with Total over the window rather than holding a condition for the
Prometheus
for:duration. Each can therefore fire sooner than its Prometheus rule. A boot that dies before its first push (about a minute) is not counted byrestart-loop;heartbeat-missingandcluster-undersizedcover a node that never comes up. The stale lock rule wants two recoveries in 30 minutes, because one recovery after a single crash is not a loop. - not-serving-dns compares with a number you supply, because an Azure metric alert cannot compare two metrics. If you enable DNS over TLS later, raise the expected DNS listeners by one. Do not raise it for DNS over HTTPS: a threshold the node can never reach fires this severity 0 rule permanently.
- Five rules are not split by node: region-discovery-incomplete, tag-registration-incomplete, dns-latency-above-slo, dns-latency-high and replication-lag-high have two criteria, and Azure allows a split on all values only in a single-criterion rule. They are correct while each node publishes to its own VM, which is what leaving the target blank does.
- tls-certificate-expiring is not split by listener: it takes the lowest
time left across the node’s listeners (the API, and DoT and DoH, which share
one certificate). It fires on the first value below 21 days, up to an hour
before the Prometheus rule’s
for: 1helapses, and clears up to six hours after the certificate is renewed or replaced. - cluster-undersized needs at least two nodes to mean anything. On a single-node deployment, leave the expected node count at 1 and the rule never fires.
- dns-latency-above-slo and dns-latency-high judge only the answers a node gives from its own zones (forwarded answers are excluded), over 15 minutes, and only once at least 100 of them were answered in that time, as the Prometheus rules do.
- region-discovery-stalled and tag-registration-stalled judge a loop only once it has run: a node publishes the age of its last run only after the first one. The two incomplete rules likewise wait for a first run, and tag registration is off until you enable it.
- replication-lag-high needs a first completed sync: a node publishes the
age of its last sync only once there has been one. A node with peers that has
never synced fires
DTMReplicationLagHighbut not this rule;cluster-undersizedand the member-unreachable rules cover a node that never joins. - replication-queue-backlog fires when the queue has held 5000 or more for 15 minutes on a node that has peers. It has two criteria, so like the rules above it is not split by node. The queue is this node’s writes not yet passed on to enough peers, so a backlog usually points at an unhealthy peer rather than at the node that fires: check the member-unreachable rules and the Nodes page first.
What route B cannot alert on
Section titled “What route B cannot alert on”Every rule in dtm-alerts.yml has a route B form except three:
- A whole-cluster outage (
DTMAllNodesDown). Every route B rule reads metrics the nodes publish themselves, so when every node is down at once, nothing is publishing and no rule has anything to judge. Pair the sample with an Azure VM availability alert or an external DNS probe for that. - A managed script check that is not running (
DTMScriptCheckNotRunning, from v1.0.5). The sample has no rule for it. The metric it reads,dtm_health_probe_refusals_total, is published like any other, so add your own rule on it, for theguardvalues that start withscript-, if you use managed scripts. - A private endpoint zone group the node cannot read
(
DTMPrivateEndpointZoneGroupUnread). The sample has no rule for it. The metric it reads,dtm_private_endpoint_zone_group_unread, is published like any other, so add your own rule on it if you use Private Endpoint automation.
On Azure, the KQL log alerts add signals the metrics do not carry, such as NXDOMAIN floods per client. They need query-log shipping to Log Analytics turned on, and they do not separate DTM’s own answers from forwarded ones, so for the latency objective rely on the sample’s DNS latency rules, not the log alerts.