Runs a log search query on a schedule and raises an alert when the result crosses a threshold, on the
2018-04-16scheduled query rules API. Targetshashicorp/azurerm ~> 4.0.
- ⚙️ Creates one
azurerm_monitor_scheduled_query_rules_alert, namedthis, with its requiredactionandtriggerblocks. - 🔴 The provider says this resource is superseded by the v2 rule and that its API version is known to cause problems — naming custom webhook properties not reaching triggered alerts as the example. That is stated in an output rather than left for you to find.
- 🔀 This module demonstrates both halves of one rule about cross-field checks.
frequencyandtime_windoware grouped into one variable because the provider documents their ordering and enforces nothing.auto_mitigation_enabledandthrottlingare deliberately left ungrouped because the provider already conflicts them in both directions. - 🧮 Emits severity in three shapes — the integer this resource takes, the
Sev0string two siblings take, and a readable label. - 🛡️ Rejects an empty action-group list, which the provider accepts and its own example demonstrates.
- 📣 Enforces the wider operator set, because the documentation's narrower list is not what the provider actually accepts.
💡 Why it matters: most of what can go wrong here is silent. A
time_windowshorter thanfrequencyleaves gaps nobody sees; an empty action group list fires alerts into nothing; a fractional threshold is type-correct and refused; a custom webhook payload is accepted and may never arrive. This module turns the first three into plan-time errors and names the fourth.
If this module saves you time:
- ⭐ Star the repository — it helps others find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
flowchart TB
rg["terraform-azurerm-resource-group"]
law["terraform-azurerm-log-analytics-workspace"]
appi["terraform-azurerm-application-insights"]
ag["terraform-azurerm-monitor-action-group"]
alert["terraform-azurerm-monitor-scheduled-query-rules-alert
v1 alerting rule, raises alerts"]
logrule["terraform-azurerm-monitor-scheduled-query-rules-log
v1 log-to-metric rule, raises nothing"]
v2["terraform-azurerm-monitor-scheduled-query-rules-alert-v2
the provider names this the successor"]
metric["terraform-azurerm-monitor-metric-alert"]
supp["terraform-azurerm-monitor-alert-processing-rule-suppression"]
rg -->|"name"| alert
rg -->|"name"| logrule
law -->|"id as data_source_id"| alert
law -->|"id as data_source_id"| logrule
appi -->|"id as data_source_id"| alert
ag -->|"id"| alert
logrule -->|"emits a metric, alerted on separately"| metric
alert -->|"raises alerts"| supp
metric -->|"raises alerts"| supp
alert -.->|"prefer for new rules"| v2
style alert fill:#0078D4,stroke:#004578,color:#ffffff
style logrule fill:#0078D4,stroke:#004578,color:#ffffff
style law fill:#004578,stroke:#004578,color:#ffffff
style rg fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style appi fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style ag fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style v2 fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style metric fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style supp fill:#F3F6F9,stroke:#8A8886,color:#201F1E
This diagram is shared with terraform-azurerm-monitor-scheduled-query-rules-log, the other v1 resource in
this family — the node text carries the difference. Both are highlighted; this module is the one that raises
alerts, and the log rule is the one that emits a metric and raises nothing.
flowchart TB
subgraph inputs["Inputs"]
i1["schedule with frequency and time_window
grouped so the ordering rule is checkable"]
i2["query plus query_type"]
i3["trigger with optional metric_trigger"]
i4["action with action_group"]
i5["auto_mitigation_enabled and throttling
kept separate, the provider conflicts them"]
end
this["azurerm_monitor_scheduled_query_rules_alert.this"]
subgraph outputs["Outputs"]
o1["id, name"]
o2["severity_string_form, the Sev0 shape"]
o3["evaluation_overlap_factor"]
o4["custom_webhook_payload_is_the_capability_the_provider_names_as_unreliable"]
o5["the_time_window_ordering_rule_is_enforced_here_not_by_the_provider"]
end
i1 --> this
i2 --> this
i3 --> this
i4 --> this
i5 --> this
this --> o1
this --> o2
this --> o3
this --> o4
this --> o5
style this fill:#0078D4,stroke:#004578,color:#ffffff
style inputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
style outputs fill:#F3F6F9,stroke:#8A8886,color:#201F1E
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_monitor_scheduled_query_rules_alert.this |
1 | The keystone |
action block |
exactly 1 | Required, MaxItems: 1 |
trigger block |
exactly 1 | Required, MaxItems: 1 |
trigger.metric_trigger block |
0–1 | Makes it a metric measurement rule |
timeouts block |
0–1 | All four operations |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module — the caller configures the provider, its auth, and its features {} block |
| ARM API version | Microsoft.Insights 2018-04-16 |
Schema notes that bite
- 🔴 Superseded by the v2 resource, per the provider's own note, which also says this API version is known to cause problems with custom webhook properties.
- 🔴
time_windowmust be ≥frequency, and nothing in the provider enforces it. - 🔴
auto_mitigation_enabledandthrottlingare mutually exclusive, and the provider does enforce that. - 🔴 The documented
trigger.operatorlist omitsEqual; the provider accepts it. - 🔴 Both thresholds are floating-point and must be whole numbers.
- 🔴
action.action_groupis required with no minimum length — the provider's own example passes[]. ⚠️ authorized_resource_idscaps at 100, in the schema only.⚠️ nameforbids< > * % & : \ ? + /, in the schema only.⚠️ frequencyandtime_windoware plain integer minutes, not ISO 8601 like the v2 rule.⚠️ severityis an optional bare integer here, aSev0string on two siblings.- Force-new:
name,resource_group_name,location,data_source_id.
| Principal | Permission | Scope | Why |
|---|---|---|---|
| The Terraform identity | Microsoft.Insights/scheduledQueryRules/write |
The rule's resource group | Create and update |
| The Terraform identity | Microsoft.Insights/scheduledQueryRules/read |
The rule | Refresh and plan |
| The Terraform identity | Microsoft.Insights/scheduledQueryRules/delete |
The rule | Destroy |
| The Terraform identity | read on the data source | The workspace or component | Azure validates it on create |
| The Terraform identity | Microsoft.Insights/actionGroups/read |
Each action group | To reference it |
| The Terraform identity | read on each authorized_resource_ids entry |
Those resources | Cross-resource authorisation |
Monitoring Contributor covers all of the above. Note the resource provider: this record is under
Microsoft.Insights, where the smart detector alert rule in the same family is under
Microsoft.AlertsManagement.
Plan access is read-only in the useful sense. id is the only computed attribute.
authorized_resource_ids is a data-access decision, not just configuration. It is what permits the
query to read beyond its single data source, so that list belongs in a review of what this rule can see.
Microsoft.Insightsregistered on the subscription.- An existing resource group and a region. This resource is regional.
- An existing Log Analytics workspace or Application Insights component as the data source.
- At least one existing action group, since the provider requires the block.
- A valid query — and with
query_type = "ResultCount", one projecting a numericAggregatedValuecolumn. - For a cross-resource query: every resource in
authorized_resource_idsmust exist.
terraform-azurerm-monitor-scheduled-query-rules-alert/
├── providers.tf # required_version, pinned azurerm; no provider block
├── variables.tf # 17 deeply-typed inputs, 32 validations
├── main.tf # the keystone plus derived facts the plan cannot show
├── outputs.tf # id first, then identity, then the two design facts
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "heartbeat_alert" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-heartbeat-missing"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = {
frequency = 5 # minutes, not PT5M
time_window = 30
}
trigger = {
operator = "LessThan"
threshold = 1
}
action = {
action_group = [var.action_group_id]
}
severity = 1
}The caller configures the provider, its authentication, and the mandatory features {} block.
Consumes
| Input | Type | Source |
|---|---|---|
resource_group_name |
Resource group name | terraform-azurerm-resource-group → name |
location |
Region | terraform-azurerm-resource-group → location |
data_source_id |
Workspace or component ID | terraform-azurerm-log-analytics-workspace → id, or terraform-azurerm-application-insights → id |
action.action_group |
Action Group IDs | terraform-azurerm-monitor-action-group → id |
Emits
| Output | Description |
|---|---|
id |
Rule Resource ID, emitted first |
this_resource_is_superseded_by_the_v2_resource |
Read this first |
custom_webhook_payload_is_the_capability_the_provider_names_as_unreliable |
Read this second |
the_time_window_ordering_rule_is_enforced_here_not_by_the_provider |
Why schedule is one variable |
auto_mitigation_and_throttling_are_mutually_exclusive_and_the_provider_enforces_it |
Why those two are not |
severity_string_form |
The Sev0 shape two siblings use |
evaluation_overlap_factor |
How much consecutive runs re-read |
1 · The minimum call
module "minimum" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-minimum"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "GreaterThan", threshold = 3 }
action = { action_group = [var.action_group_id] }
}ℹ️ Eight of the seventeen inputs are required, because the provider requires them.
severityis deliberately left unset — the provider declares no default, and inventing one would put a severity in every plan the caller never chose.
2 · Before you use this: the supersession
module "legacy_rule" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-legacy"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "LessThan", threshold = 1 }
action = { action_group = [var.action_group_id] }
}
output "read_this_before_relying_on_it" {
value = {
superseded = module.legacy_rule.this_resource_is_superseded_by_the_v2_resource
webhook_unreliable = module.legacy_rule.custom_webhook_payload_is_the_capability_the_provider_names_as_unreliable
}
}
⚠️ Both readtruefor every configuration. The provider states this resource is superseded by the v2 rule and that its API version is known to cause problems.What that does not mean: the API version is not deprecated, and existing rules keep working. What was retired in October 2025 was a different and older API — the legacy Log Analytics Alert API — which this resource never used. Existing v1 rules are fine; new ones have little reason to start here.
3 · The rule the provider documents and does not enforce
# REJECTED at plan time by this module.
module "gap_in_the_data" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-gappy"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = {
frequency = 60
time_window = 30 # shorter than the interval
}
trigger = { operator = "GreaterThan", threshold = 3 }
action = { action_group = [var.action_group_id] }
}🔒 A window shorter than the evaluation interval means each run looks at 30 minutes of data every 60 minutes, so half the data is never examined. The provider documents that
time_windowmust be greater than or equal tofrequencyand enforces nothing — noConflictsWith, noCustomizeDiff.A Terraform validation may only reference its own variable, so as two separate inputs this could not be checked at all. Grouping them into
scheduleis what makes it a plan-time error:schedule.time_window must be greater than or equal to schedule.frequency. A window shorter than the evaluation interval leaves gaps in the data the rule never looks at.
4 · The pairing this module deliberately does not check
# Rejected by THE PROVIDER, not by this module.
module "conflicting_mitigation" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-conflict"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "GreaterThan", threshold = 3 }
action = { action_group = [var.action_group_id] }
auto_mitigation_enabled = true
throttling = 60 # mutually exclusive with the line above
}💡 Contrast this with example 3. These two fields are also mutually exclusive and also documented — but the provider declares the conflict in both directions and rejects the pair itself, naming both fields. So this module leaves them as separate variables and adds no check.
Duplicating an enforcement that already works would create a second place to keep correct if Microsoft changed the rule, and a module that contradicted the provider would be worse than one that stayed quiet. Reading the provider's source is what decides which of these two treatments a documented rule gets.
5 · The empty action group the provider accepts
# REJECTED at plan time by this module.
module "notifies_nobody" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-silent"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "GreaterThan", threshold = 3 }
action = {
action_group = []
}
}🔒 The provider requires the
action_groupattribute but sets no minimum on its contents — and its own documentation example passes an empty list, which makes this shape likelier to be copied than invented. The result is a rule that evaluates, fires, and tells nobody. No error, no drift, nothing in the plan.
6 · A metric measurement rule
module "metric_measurement" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-failed-logins-per-computer"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = <<-QUERY
SecurityEvent
| where EventID == 4625
| summarize AggregatedValue = count() by Computer, bin(TimeGenerated, 5m)
QUERY
schedule = { frequency = 5, time_window = 30 }
trigger = {
operator = "GreaterThan"
threshold = 3
metric_trigger = {
metric_trigger_type = "Consecutive"
operator = "GreaterThan"
threshold = 2
metric_column = "Computer"
}
}
action = { action_group = [var.action_group_id] }
severity = 2
}💡 With a
metric_triggerthe rule fires on how many times a measured value crossed the threshold rather than on a single evaluation.metric_trigger_typeisConsecutiveorTotal, andmetric_columngroups the measurement — here, per computer.
7 · The operator the documentation omits
module "equal_operator" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-exactly-zero"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = {
operator = "Equal" # not in the documented list for this field
threshold = 0
}
action = { action_group = [var.action_group_id] }
}ℹ️ The resource documentation lists four values for
trigger.operator, omittingEqual, and five fortrigger.metric_trigger.operator. In the provider's own source both sets are identical and both includeEqual.Since the narrower claim is not backed by any rejection, this module enforces the wider set. Enforcing the documented four would have refused a value the provider accepts — which is why the discrepancy is recorded in
the_documented_operator_set_omits_equal_but_the_provider_accepts_itrather than silently inherited.
8 · The threshold that looks fine and is refused
# REJECTED at plan time by this module.
module "fractional_threshold" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-fractional"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = {
operator = "GreaterThan"
threshold = 3.5 # type-correct, and rejected
}
action = { action_group = [var.action_group_id] }
}
⚠️ Both thresholds are floating-point in the provider's schema, because the SDK beneath it types them that way — and the provider then rejects anything that is not a whole number. The documentation mentions only the 0 to 10000 range, so3.5looks perfectly valid until it is refused. This module checks integrality separately and says why in the message.
9 · Severity in three shapes
module "severity_demo" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-severity-demo"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "LessThan", threshold = 1 }
action = { action_group = [var.action_group_id] }
severity = 0
}
output "severity_shapes" {
value = {
as_this_resource_takes_it = module.severity_demo.severity # 0
as_two_siblings_take_it = module.severity_demo.severity_string_form # "Sev0"
with_its_meaning = module.severity_demo.severity_label
}
}
⚠️ Within one family: this resource and the v2 rule take a bare integer, while the smart detector alert rule and the alert processing rules take the string"Sev0". Every scale is inverted — 0 is the most severe.severity_string_formexists so a composition spanning them does not convert by hand.
10 · Overlapping evaluations, made visible
module "overlapping" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-overlapping"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = {
frequency = 5
time_window = 30 # each run re-reads 6x the interval
}
trigger = { operator = "GreaterThan", threshold = 3 }
action = { action_group = [var.action_group_id] }
}
output "how_much_overlap" {
value = {
factor = module.overlapping.evaluation_overlap_factor # 6
overlaps = module.overlapping.evaluations_overlap # true
}
}ℹ️ Legal and usually intended, but worth seeing: with a 30-minute window every 5 minutes, one spike can raise up to six alerts as it stays inside the window. Set
time_windowequal tofrequencyfor non-overlapping evaluations, or addthrottling.
11 · Throttling repeat alerts
module "throttled" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-throttled"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "GreaterThan", threshold = 3 }
action = { action_group = [var.action_group_id] }
throttling = 60 # minutes; cannot be combined with auto_mitigation_enabled
}💡 Unset means every qualifying evaluation raises an alert, which is this module's default because hearing less about an ongoing problem should be typed deliberately. Remember the provider will reject this alongside
auto_mitigation_enabled = true.
12 · A cross-resource query
module "cross_resource" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-cross-resource"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.application_insights_id
# The query reads a second component as well as its own data source.
authorized_resource_ids = [var.second_application_insights_id]
query = <<-QUERY
let a = requests | where toint(resultCode) >= 500 | extend fail = 1;
let b = app('appi-secondary').requests | where toint(resultCode) >= 500 | extend fail = 1;
a | join b on fail | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)
QUERY
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "GreaterThan", threshold = 3 }
action = { action_group = [var.action_group_id] }
}
output "query_reach" {
value = {
authorized = module.cross_resource.authorized_resource_ids
is_cross = module.cross_resource.cross_resource_query_is_authorized
cap = module.cross_resource.authorized_resource_ids_cap_as_of_authoring
}
}🔒
authorized_resource_idswidens what the query may read beyond the singledata_source_ida reviewer would infer. Treat the list as a data-access decision. The provider caps it at 100 — a limit in its schema and not in its documentation, which is why the output name carriesas_of_authoring.
13 · The query-text rule this module reports rather than enforces
module "no_aggregated_value" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-no-aggval"
resource_group_name = var.resource_group_name
location = var.location
data_source_id = var.log_analytics_workspace_id
query = "Heartbeat | count" # no AggregatedValue column
query_type = "ResultCount"
schedule = { frequency = 5, time_window = 30 }
trigger = { operator = "GreaterThan", threshold = 3 }
action = { action_group = [var.action_group_id] }
}
check "resultcount_queries_project_aggregatedvalue" {
assert {
condition = !module.no_aggregated_value.query_type_requires_an_aggregatedvalue_column || module.no_aggregated_value.query_text_mentions_aggregatedvalue
error_message = "query_type is ResultCount, which requires the query to project a numeric AggregatedValue column, and the identifier does not appear in the query text."
}
}ℹ️ The module does not parse the query.
query_text_mentions_aggregatedvalueis a substring hint — a query could name the column indirectly and readfalse, or mention the word in a comment and readtrue. Acheckblock is the right home for a warning built on a hint, which is why the module reports rather than rejects.
14 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "observability_rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-observability-eastus2"
location = "eastus2"
tags = { workload = "platform", managed_by = "terraform" }
}
module "workspace" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"
name = "law-platform-eastus2"
location = module.observability_rg.location
resource_group_name = module.observability_rg.name
tags = { workload = "platform", managed_by = "terraform" }
}
module "oncall" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-action-group.git?ref=v1.0.0"
name = "ag-platform-oncall"
resource_group_name = module.observability_rg.name
short_name = "platoncall"
tags = { workload = "platform", managed_by = "terraform" }
}
module "heartbeat_missing" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-scheduled-query-rules-alert.git?ref=v1.0.0"
name = "sqr-heartbeat-missing"
resource_group_name = module.observability_rg.name
location = module.observability_rg.location
# The workspace's ARM Resource ID, not the workspace_id GUID the same module also emits.
data_source_id = module.workspace.id
query = "Heartbeat | summarize AggregatedValue = count() by bin(TimeGenerated, 5m)"
schedule = {
frequency = 5
time_window = 5 # non-overlapping: one alert per gap
}
trigger = {
operator = "LessThan"
threshold = 1
}
action = {
action_group = [module.oncall.id]
email_subject = "Heartbeat missing on the platform workspace"
}
severity = 1
throttling = 30
description = "No heartbeat received in the last five minutes."
tags = { workload = "platform", managed_by = "terraform" }
}
module "quiet_hours" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-suppression.git?ref=v1.0.0"
name = "apr-platform-quiet-hours"
resource_group_name = module.observability_rg.name
scopes = [module.observability_rg.id]
tags = { workload = "platform", managed_by = "terraform" }
}
check "the_alert_can_reach_someone" {
assert {
condition = module.heartbeat_missing.action_group_count > 0
error_message = "The rule has no action groups, so a fired alert would notify nobody."
}
}
check "evaluations_do_not_overlap" {
assert {
condition = !module.heartbeat_missing.evaluations_overlap
error_message = "time_window exceeds frequency, so one gap can raise several alerts. Intended here to be non-overlapping."
}
}
check "the_data_source_is_a_workspace_or_component" {
assert {
condition = !module.heartbeat_missing.data_source_is_neither_workspace_nor_component
error_message = "The data source is neither a Log Analytics workspace nor an Application Insights component, so the query very likely has nothing to read."
}
}
output "alerting_posture" {
value = {
rule_id = module.heartbeat_missing.id
severity = module.heartbeat_missing.severity_label
severity_as_string = module.heartbeat_missing.severity_string_form
notifies = module.heartbeat_missing.action_group_count
overlap_factor = module.heartbeat_missing.evaluation_overlap_factor
prefer_v2_for_new = module.heartbeat_missing.this_resource_is_superseded_by_the_v2_resource
webhook_caveat = module.heartbeat_missing.custom_webhook_payload_is_the_capability_the_provider_names_as_unreliable
}
}🏗️ Two things the composition makes visible that no single plan would.
module.workspace.idfeedsdata_source_idbecause that field wants the workspace's ARM Resource ID, not theworkspace_idGUID the same module also emits. Andprefer_v2_for_newistruefor every configuration — this composition is the shape to use for an existing v1 estate, while a greenfield rule belongs onterraform-azurerm-monitor-scheduled-query-rules-alert-v2.
Required (8)
| Name | Type | Description |
|---|---|---|
name |
string |
Rule name. Force-new; forbids < > * % & : \ ? + /. |
resource_group_name |
string |
Resource group name. Force-new. |
location |
string |
Region. Force-new. |
data_source_id |
string |
Workspace or component Resource ID. Force-new. |
query |
string |
The log search query. |
schedule |
object(...) |
frequency and time_window, in minutes, grouped for their ordering rule. |
trigger |
object(...) |
The condition, with an optional metric_trigger. |
action |
object(...) |
Required by the provider; at least one action group required here. |
Optional (9)
| Name | Type | Default | Description |
|---|---|---|---|
query_type |
string |
"ResultCount" |
ResultCount or Number. |
severity |
number |
null |
0–4, inverted. No provider default. |
authorized_resource_ids |
set(string) |
[] |
Cross-resource query reach. Max 100. |
auto_mitigation_enabled |
bool |
false |
Self-resolve. Conflicts with throttling. |
throttling |
number |
null |
Suppression minutes, 0–10000. |
description |
string |
null |
1–4096 characters. |
enabled |
bool |
true |
Whether it evaluates. |
tags |
map(string) |
{} |
Max 50. |
timeouts |
object(...) |
null |
Go durations; all four operations. |
Full input schemas
variable "schedule" {
type = object({
frequency = number # minutes, 5-1440
time_window = number # minutes, 5-2880, must be >= frequency
})
# Grouped because the provider documents the ordering rule and enforces nothing.
}
variable "trigger" {
type = object({
operator = string # GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Equal
threshold = number # whole number 0-10000
metric_trigger = optional(object({
metric_trigger_type = string # Consecutive or Total
operator = string # the same five values
threshold = number # whole number 0-10000
metric_column = optional(string)
}))
})
# The documented operator list for the outer field omits Equal; the provider accepts it.
}
variable "action" {
type = object({
action_group = set(string) # at least one Action Group Resource ID
custom_webhook_payload = optional(string) # valid JSON; see the reliability caveat
email_subject = optional(string)
})
}
variable "severity" {
type = number
default = null
# 0-4, whole number, 0 most severe. Two siblings in this family take "Sev0" instead.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
# Go durations here: 30m. Plain minutes in schedule. ISO 8601 on the v2 resource.
}This resource supports
tagsand requires alocation, so this module carries the full universal tail.
| Output | Description | Notes |
|---|---|---|
id |
Rule Resource ID | Emitted first |
name / resource_group_name / location / enabled |
Identity | |
this_resource_is_superseded_by_the_v2_resource |
Constant true |
Read first |
custom_webhook_payload_is_the_capability_the_provider_names_as_unreliable |
Constant true |
Read second |
data_source_id / data_source_type / data_source_name |
The target, parsed | |
data_source_resource_group_name / data_source_subscription_id |
Where it lives | |
data_source_is_neither_workspace_nor_component |
Reported, not rejected | check blocks |
frequency_minutes / time_window_minutes |
Plain minutes | |
evaluation_overlap_factor / evaluations_overlap |
Re-read factor | Floored to 2dp |
the_time_window_ordering_rule_is_enforced_here_not_by_the_provider |
Constant true |
Why schedule is grouped |
severity / severity_string_form / severity_label / severity_is_set |
Three shapes | Cross-family |
query_type / query_type_requires_an_aggregatedvalue_column |
The interpretation | |
query_text_mentions_aggregatedvalue |
A hint, not a proof | check blocks |
trigger_operator / trigger_threshold |
The condition | |
has_metric_trigger / metric_trigger_type / metric_trigger_column |
Measurement mode | |
the_documented_operator_set_omits_equal_but_the_provider_accepts_it |
Constant true |
|
thresholds_are_floating_point_but_must_be_whole_numbers |
Constant true |
|
action_group_ids / action_group_count |
Notification | |
alert_fires_but_notifies_nobody |
Always false here |
Assertable anyway |
has_custom_webhook_payload / has_email_subject |
Presence only | Payload never emitted |
custom_action_fields_depend_on_receivers_this_module_cannot_see |
Constant true |
|
authorized_resource_ids / cross_resource_query_is_authorized |
Query reach | Data-access review |
authorized_resource_ids_cap_as_of_authoring |
100 |
From the schema |
auto_mitigation_enabled / throttling_minutes / throttles_repeat_alerts |
Post-fire | |
auto_mitigation_and_throttling_are_mutually_exclusive_and_the_provider_enforces_it |
Constant true |
Why they are not grouped |
tags |
Applied tags | |
accepts_no_credential |
Constant true |
With one caveat |
No secret is emitted. Nothing here is marked
sensitive, because nothing here is a secret.
One resource, two opposite treatments of a documented rule. This module is the clearest example in the
library of why the provider's source has to be read before adding a cross-field check. time_window must be at
least frequency: documented, and enforced nowhere — so the two fields are grouped into schedule, because a
validation can only see its own variable and grouping is the only way the rule becomes checkable.
auto_mitigation_enabled and throttling are mutually exclusive: also documented, and the provider declares
ConflictsWith in both directions — so they stay separate and this module adds nothing. Same kind of rule,
opposite action, and the deciding evidence is one grep of the provider's schema.
Where the documentation and the provider disagree, the provider wins. The documented operator list for
trigger.operator omits Equal; the provider's set includes it. The narrower claim is not backed by a
rejection, so enforcing it would refuse legal input. The same reading applies to three facts the documentation
omits entirely and the schema states plainly: the 100-entry cap on authorized_resource_ids, the forbidden
characters in name, and the 4096-character bound on description.
A threshold that is type-correct and refused. Both thresholds are floating-point because the SDK types them
that way, and the provider rejects any fractional value. Nothing in the documentation says so, so 3.5 looks
valid right up to the error.
The supersession, stated plainly. The provider calls this resource superseded and its API version known to cause problems, giving custom webhook properties as the example. That matters most because a custom webhook payload is one of only two things this resource can do that v2 cannot — so the reason to choose v1 is precisely the area the provider flags. For an existing v1 estate this module is the right tool; for a new rule, v2 is.
Three duration notations in one family. Plain integer minutes in schedule, Go durations in timeouts, and
ISO 8601 on the v2 resource for the same two concepts. None accepts another's spelling.
Overlap is arithmetic nobody shows you. A 30-minute window every 5 minutes re-reads the same data six
times, so one spike can raise six alerts. evaluation_overlap_factor makes that visible; throttling or an
equal window is the fix.
Notification reachability is structurally optional. The provider requires the action block and requires
its action_group attribute, but permits that attribute to be empty — and demonstrates the empty form in its
own documentation. That is the one shape where this resource works perfectly and tells nobody, so the module
refuses it.
| Concern | This module's default (empty call) | Opt-out |
|---|---|---|
| Evaluation | enabled = true — a disabled rule is a silent gap |
set false |
| Re-notification | no throttle — every qualifying evaluation alerts | set throttling |
| Notification reachability | at least one action group required | none; an empty list is refused |
| Data source shape | subscription and resource-group IDs rejected | none; nothing to query there |
| Query reach | authorized_resource_ids = [] — own data source only |
list the extra resources |
| Auto-resolution | auto_mitigation_enabled = false, the provider's value |
set true |
| Severity | unset, matching the provider's absence of a default | set 0–4 |
| Secrets | none accepted, none emitted | none |
Four provider defaults were examined and none was flipped. enabled was already detection-preserving. An
unset throttling is already the loudest setting. auto_mitigation_enabled = false stands because
auto-resolution is a convenience, not a safety property — an alert that closes itself can close while the
problem persists. And severity is left unset because the provider declares no default, and inventing one would
put a severity in every plan the caller never chose.
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in CI; a human applies.
terraform validate proves the configuration parses and every type resolves; terraform fmt -check proves the
formatting. Neither runs a variable validation block on this module as a called module — but
terraform console does, when this directory is the root:
echo 'null' | terraform console -var-file=bad.tfvarsAll 32 validations were proven to fire this way, identified by line number rather than message text, with
terraform fmt run before the proof so no later reformatting could shift the numbers.
Three checks necessarily co-fire with a companion, and each is structural rather than accidental: a bare name in
data_source_id fails both the leading-/subscriptions/ check and the resource-level shape check; a wrong-case
query_type is by definition also outside the case-sensitive set; and a Resource ID passed as name trips the
forbidden-character check too, because every Resource ID contains /. In each pair the broad check catches the
value and the narrow one supplies the explanation.
Nine good fixtures were confirmed to fire nothing, covering every branch that must not be rejected: Equal
as the outer operator, a full metric_trigger with a 10000 threshold, time_window equal to frequency, the
maximum window, an Application Insights data source with query_type = "Number", a query with no
AggregatedValue, and auto_mitigation_enabled = true on its own — which must stay silent here, because only
the provider polices that pairing.
Segment arithmetic on data_source_id was verified per resource type rather than assumed, and the overlap factor
was checked against a deliberately fractional ratio to confirm it never renders as a repeating decimal.
What only a real plan or apply can exercise: whether the data source exists, whether the query is valid
against it, whether it projects an AggregatedValue column, and whether the referenced action groups have the
receivers email_subject and custom_webhook_payload depend on.
id = "/subscriptions/.../resourceGroups/rg-observability-eastus2/providers/Microsoft.Insights/scheduledQueryRules/sqr-heartbeat-missing"
name = "sqr-heartbeat-missing"
location = "eastus2"
this_resource_is_superseded_by_the_v2_resource = true
custom_webhook_payload_is_the_capability_the_provider_names_as_unreliable = true
data_source_type = "Log Analytics workspace"
data_source_name = "law-platform-eastus2"
frequency_minutes = 5
time_window_minutes = 5
evaluation_overlap_factor = 1
evaluations_overlap = false
severity = 1
severity_string_form = "Sev1"
severity_label = "1 - error"
query_type = "ResultCount"
query_type_requires_an_aggregatedvalue_column = true
query_text_mentions_aggregatedvalue = true
trigger_operator = "LessThan"
trigger_threshold = 1
has_metric_trigger = false
action_group_count = 1
alert_fires_but_notifies_nobody = false
has_email_subject = true
has_custom_webhook_payload = false
cross_resource_query_is_authorized = false
throttling_minutes = 30
auto_mitigation_enabled = false
accepts_no_credential = true
| Symptom | Cause | Fix |
|---|---|---|
| Custom webhook payload never arrives at the receiver | The provider names this as a known problem on this API version | Use the v2 resource; see custom_webhook_payload_is_the_capability_the_provider_names_as_unreliable |
| The rule misses events | time_window is shorter than frequency, leaving gaps |
The module now rejects this |
Plan rejects auto_mitigation_enabled with throttling |
The provider conflicts them in both directions | Set one or the other |
| One spike raised six alerts | time_window is six times frequency, so evaluations overlap |
Equalise them or set throttling; see evaluation_overlap_factor |
| A fired alert notified nobody | action.action_group was empty |
The module now rejects this |
threshold = 3.5 refused although the range allows it |
Thresholds must be whole numbers, undocumented | Use an integer |
operator = "Equal" seemed invalid per the docs |
The documentation omits it; the provider accepts it | Use it; see the operator output |
name rejected |
It contains one of < > * % & : \ ? + / |
Rename; the restriction is in the schema only |
| The rule fires but the query returns nothing useful | query_type = "ResultCount" without an AggregatedValue column |
Project one, or use query_type = "Number" |
| Query cannot read a second resource | It is not in authorized_resource_ids |
Add it; the cap is 100 |
| A timeout appears to have no effect | Misspelled timeouts key, silently discarded |
Check against the four declared keys |
| Changing the data source replaced the rule | data_source_id is force-new |
Expected; one of four force-new fields |
azurerm_monitor_scheduled_query_rules_alert- Log search alert rules in Azure Monitor
- Manage alert rules created in previous versions
- Optimize log search alert queries
- Sibling modules:
terraform-azurerm-monitor-scheduled-query-rules-alert-v2,terraform-azurerm-monitor-scheduled-query-rules-log,terraform-azurerm-log-analytics-workspace,terraform-azurerm-application-insights,terraform-azurerm-monitor-action-group,terraform-azurerm-monitor-alert-processing-rule-suppression - This module's
SCOPE.md
💙 "Infrastructure as Code should be standardized, consistent, and secure."