Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Scheduled Query Rule (Alert v1) Terraform Module

Runs a log search query on a schedule and raises an alert when the result crosses a threshold, on the 2018-04-16 scheduled query rules API. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • ⚙️ Creates one azurerm_monitor_scheduled_query_rules_alert, named this, with its required action and trigger blocks.
  • 🔴 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. frequency and time_window are grouped into one variable because the provider documents their ordering and enforces nothing. auto_mitigation_enabled and throttling are deliberately left ungrouped because the provider already conflicts them in both directions.
  • 🧮 Emits severity in three shapes — the integer this resource takes, the Sev0 string 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_window shorter than frequency leaves 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.


❤️ Support this project

If this module saves you time:


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

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

✅ Provider / Versions

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_window must be ≥ frequency, and nothing in the provider enforces it.
  • 🔴 auto_mitigation_enabled and throttling are mutually exclusive, and the provider does enforce that.
  • 🔴 The documented trigger.operator list omits Equal; the provider accepts it.
  • 🔴 Both thresholds are floating-point and must be whole numbers.
  • 🔴 action.action_group is required with no minimum length — the provider's own example passes [].
  • ⚠️ authorized_resource_ids caps at 100, in the schema only.
  • ⚠️ name forbids < > * % & : \ ? + /, in the schema only.
  • ⚠️ frequency and time_window are plain integer minutes, not ISO 8601 like the v2 rule.
  • ⚠️ severity is an optional bare integer here, a Sev0 string on two siblings.
  • Force-new: name, resource_group_name, location, data_source_id.

🔑 Required Azure RBAC Roles / Permissions

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.


Azure Prerequisites

  • Microsoft.Insights registered 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 numeric AggregatedValue column.
  • For a cross-resource query: every resource in authorized_resource_ids must exist.

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

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

📚 Example Library

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. severity is 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 read true for 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_window must be greater than or equal to frequency and enforces nothing — no ConflictsWith, no CustomizeDiff.

A Terraform validation may only reference its own variable, so as two separate inputs this could not be checked at all. Grouping them into schedule is 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_group attribute 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_trigger the rule fires on how many times a measured value crossed the threshold rather than on a single evaluation. metric_trigger_type is Consecutive or Total, and metric_column groups 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, omitting Equal, and five for trigger.metric_trigger.operator. In the provider's own source both sets are identical and both include Equal.

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_it rather 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, so 3.5 looks 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_form exists 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_window equal to frequency for non-overlapping evaluations, or add throttling.

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_ids widens what the query may read beyond the single data_source_id a 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 carries as_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_aggregatedvalue is a substring hint — a query could name the column indirectly and read false, or mention the word in a comment and read true. A check block 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.id feeds data_source_id because that field wants the workspace's ARM Resource ID, not the workspace_id GUID the same module also emits. And prefer_v2_for_new is true for every configuration — this composition is the shape to use for an existing v1 estate, while a greenfield rule belongs on terraform-azurerm-monitor-scheduled-query-rules-alert-v2.


📥 Inputs

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 tags and requires a location, so this module carries the full universal tail.


🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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

⚠️ Eight of the seventeen inputs are required, so there is no fully empty call. Where a required argument is security-relevant the module validates the value set and names the legal values in the error message.

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.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. This module is plan-only in CI; a human applies.


🧪 Testing

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.tfvars

All 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.


💬 Example Output

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

🔍 Troubleshooting

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

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."