A saved KQL search in a Log Analytics workspace (
azurerm_log_analytics_saved_search) — or, withfunction_alias, a reusable function other queries call by name.⚠️ Every field is force-new. Targetshashicorp/azurerm ~> 4.0.
- 🔎 Declares one saved KQL search, as a keystone resource named
this. - 🧩 Function mode is the more valuable use. Setting
function_aliaspromotes the search into a reusable function — how you stop twelve alert rules each carrying its own copy of the same subtly-different join. ⚠️ Every field is force-new, includingtags. The provider defines no update operation, so the module'stimeoutsobject carries noupdate— and a bulk re-tagging exercise across a workspace destroys and recreates every saved search.- 🕳️ A function alias is a published API whose callers Terraform cannot see. Renaming it breaks every query and alert rule that calls it, with nothing to warn you.
- 🧪 Nothing validates the KQL. A query that is wrong or names a missing table applies cleanly and fails only when someone runs it. The module checks what it can — non-empty after trimming — and says plainly that this is the limit.
- 🚫 Rejects
function_parameterswithoutfunction_alias: without an alias the saved search is not a function, so the parameters bind to nothing. The provider accepts the combination and stores something inert.
💡 Why it matters: Two things here behave unlike almost any other resource in this library. There is no update path at all, so edits you would expect to be trivial — fixing a display name, adding a tag — are destroy-and-create. And the resource can publish an interface (a function) that other things depend on without those dependencies existing in Terraform's graph, which makes a rename a silent breaking change.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart LR
rg["terraform-azurerm-resource-group"]
ws["terraform-azurerm-log-analytics-workspace: the family keystone, also owns data-export rules and solutions"]
cluster["terraform-azurerm-log-analytics-cluster: dedicated cluster for CMK and commitment-tier pricing"]
cmk["terraform-azurerm-log-analytics-cluster-customer-managed-key: bind the key AFTER the identity is granted on the vault"]
qp["terraform-azurerm-log-analytics-query-pack"]
qpq["terraform-azurerm-log-analytics-query-pack-query: name is a GUID, display_name is the label"]
ls["terraform-azurerm-log-analytics-linked-service: Automation Account OR Cluster, two unrelated links in one resource"]
lsa["terraform-azurerm-log-analytics-linked-storage-account: redirect ONE data class, by Resource ID, NO keys"]
si["terraform-azurerm-log-analytics-storage-insights: READ storage. REQUIRES a full-control account KEY."]
ss["terraform-azurerm-log-analytics-saved-search: query or reusable FUNCTION. Every field force-new."]
wt["terraform-azurerm-log-analytics-workspace-table: per-table retention and PLAN. The only one of the three with plan."]
wtcl["terraform-azurerm-log-analytics-workspace-table-custom-log: CREATES a _CL table and owns its columns"]
wtms["terraform-azurerm-log-analytics-workspace-table-microsoft: six Microsoft tables only. NO plan argument."]
dwe["terraform-azurerm-log-analytics-datasource-windows-event: LEGACY agent path"]
dwp["terraform-azurerm-log-analytics-datasource-windows-performance-counter: LEGACY agent path"]
aa["terraform-azurerm-automation-account"]
sa["terraform-azurerm-storage-account"]
kv["terraform-azurerm-key-vault: soft delete AND purge protection REQUIRED"]
dcr["terraform-azurerm-monitor-data-collection-rule: the MODERN replacement for both datasource modules, and how data reaches a _CL table"]
ds["terraform-azurerm-monitor-diagnostic-setting: the KEYLESS alternative to storage-insights"]
rg -->|"resource_group_name, location"| ws
ws -->|"id, BY ID"| lsa
ws -->|"id, BY ID"| si
ws -->|"id as log_analytics_workspace_id"| ss
ws -->|"id, BY ID"| wt
ws -->|"id, BY ID"| wtcl
ws -->|"id, BY ID"| wtms
ws -->|"id, BY ID"| ls
ws -->|"name plus resource_group_name, BY NAME"| dwe
ws -->|"name plus resource_group_name, BY NAME"| dwp
qp -->|"id, BY ID"| qpq
aa -->|"id as read_access_id"| ls
cluster -->|"id as write_access_id, workspace sku must be LACluster"| ls
cluster -->|"id, plus identity_principal_id feeds the vault grant"| cmk
kv -->|"key versionless_id, prefer unpinned"| cmk
sa -->|"ids, no keys"| lsa
sa -->|"id PLUS a full-control account key"| si
dcr -->|"prefer this for anything new"| dwe
dcr -->|"prefer this for anything new"| dwp
dcr -->|"custom stream writes into the table"| wtcl
ds -->|"prefer this to avoid the key"| si
wt -->|"OVERLAP: both own retention on the same table. Pick ONE owner."| wtms
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class ws keystone;
class cluster,qp,ls,lsa,si,ss,wt,wtcl,wtms,cmk,qpq,dwe,dwp me;
class rg,aa,sa,kv,dcr,ds sib;
flowchart TB
what["A saved KQL search in a workspace: a query people open from the portal, OR a reusable FUNCTION when function_alias is set"]
fn["EVERY FIELD IS FORCE-NEW, INCLUDING tags: the provider defines no update operation at all"]
tail["which is why the timeouts object carries create, read and delete but NO update"]
retag["consequence worth knowing before you run one: a bulk re-tagging exercise across a workspace DESTROYS AND RECREATES every saved search"]
label["and a wording fix to display_name, a user-visible label, also destroys and recreates it"]
func["FUNCTION MODE is the more valuable use: a function is how you stop twelve alert rules each carrying its own copy of the same subtly-different join"]
api["but once queries and alert rules call the alias it is a PUBLISHED API"]
invisible["and its callers are NOT in Terraform's dependency graph, so renaming the alias breaks them with NOTHING to warn you"]
params["function_parameters on a NON-function binds to nothing. The provider stores it anyway, so this module refuses the combination at terraform validate"]
cycle["that check lives on function_parameters and reads function_alias one-directionally, to avoid a validation cycle"]
nokql["NOTHING VALIDATES THE KQL: a query that is syntactically wrong, or names a table that does not exist, applies cleanly and fails only when someone RUNS it"]
empty["so query is at least checked non-empty after trimming, which catches the realistic failure: a wrong file path resolving to an empty string"]
field["takes log_analytics_workspace_id, where siblings take workspace_id or workspace_name plus resource_group_name. A copied line will not work."]
pack["for a shared RBAC-governed library across workspaces, terraform-azurerm-log-analytics-query-pack is the better shape"]
this["terraform-azurerm-log-analytics-saved-search"]
keystone["azurerm_log_analytics_saved_search.this"]
what -->|"read this first"| this
fn -->|"read this too"| tail
tail -->|"and"| retag
retag -->|"and"| label
func -->|"why use it"| api
api -->|"the hazard"| invisible
params -->|"caught at plan"| func
cycle -->|"how"| params
nokql -->|"the limit"| empty
empty -->|"what IS checked"| this
field -->|"family inconsistency"| this
pack -->|"the alternative shape"| this
invisible -->|"disclosed"| this
this -->|"creates"| keystone
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class keystone keystone;
class what,fn,tail,retag,label,func,api,invisible,params,cycle,nokql,empty,field,pack sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_log_analytics_saved_search.this |
1 | The keystone. Every field force-new, including tags. No update operation exists. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication. |
Schema notes that bite — confirmed against the live provider schema:
- Every field is force-new — including
tags. The provider exposes onlycreate,readanddeletetimeouts, because there is no update operation. The module'stimeoutsobject omitsupdateto match. - Nothing validates the KQL. Syntactically wrong queries, and queries naming tables that do not exist, apply cleanly.
function_parameterson a non-function binds to nothing. The provider accepts and stores it rather than rejecting it, so the module refuses the combination atterraform validate— one of the few refusals here that no legal configuration wants.- The workspace input is
log_analytics_workspace_idhere, wherestorage-insights,linked-storage-accountandworkspace-tableuseworkspace_idand the twodatasource-windows-*modules useworkspace_nameplusresource_group_name. A line copied between these modules will not work. nameanddisplay_nameare different things.nameis the internal record identifier;display_nameis the portal label. Both are force-new.- The resource does support
tags, unusually for this family — but see the force-new note above.
| Scope | Role / permission | Why |
|---|---|---|
| The workspace | Log Analytics Contributor, or a custom role with Microsoft.OperationalInsights/workspaces/savedSearches/* |
The saved search is a child of the workspace. |
Running the query needs only Log Analytics Reader on the workspace — a separate, narrower grant that this module does not manage.
⚠️ Note the operational consequence of function mode: whoever can apply this configuration can change the behaviour of every alert rule that calls the function, without touching those alert rules. Scope the role with that in mind.
- The
Microsoft.OperationalInsightsresource provider registered on the subscription. - An existing Log Analytics workspace.
- KQL that has been run against the workspace at least once. Nothing here validates it, so an untested query becomes a saved search that fails for whoever opens it.
terraform-azurerm-log-analytics-saved-search/
├── providers.tf # required_version + the pinned azurerm provider. No provider block.
├── variables.tf # workspace id, name/category/display_name, query, function mode, tags, timeouts (no update)
├── main.tf # the keystone azurerm_log_analytics_saved_search.this
├── outputs.tf # id, name, display_name, category, workspace, function_alias, is_function
├── README.md # this document
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore
provider "azurerm" {
features {}
}
module "failed_signins" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-saved-search.git?ref=v1.0.0"
log_analytics_workspace_id = module.law.id
name = "failed-signins"
category = "Identity"
display_name = "Failed sign-ins, last 24h"
query = file("${path.module}/queries/failed-signins.kql")
}ℹ️ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
⚠️ Any later edit to this — including the display name or a tag — destroys and recreates the search. See example 3.
Consumes
| Input | Type | Source module |
|---|---|---|
log_analytics_workspace_id |
string |
terraform-azurerm-log-analytics-workspace → id |
query |
string |
a caller-owned .kql file, via file() |
category / display_name |
string |
caller decision |
function_alias / function_parameters |
string / list(string) |
caller decision — function mode |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The saved search's Resource ID. | imports, policy checks |
name |
The internal record name. Force-new. | review |
display_name |
The portal label. Force-new. | review |
category |
The portal grouping. | review |
log_analytics_workspace_id |
The containing workspace. | review |
function_alias |
The alias other queries call, or null. |
dependent queries, review |
is_function |
Derived — whether other things may depend on this. | policy checks |
ℹ️ The query body is deliberately not emitted: it is a long document already in the caller's files, and re-emitting it would put it into every consumer's state and plan output for no benefit.
1 · Keep the KQL in a file
query = file("${path.module}/queries/failed-signins.kql")# ⚠️ Works, and buries a reviewable artefact inside infrastructure code.
query = <<-KQL
SigninLogs | where ResultType != 0 | summarize count() by UserPrincipalName
KQL💡 A query in its own file can be linted, diffed and reviewed by people who never open Terraform — and KQL in a heredoc is where quoting mistakes hide.
ℹ️
queryis validated non-empty after trimming, which catches the realistic failure: a wrongfile()path resolving to an empty string, producing a saved search that looks fine in state and does nothing.
2 · Function mode — the more valuable use
module "recent_failures_fn" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-saved-search.git?ref=v1.0.0"
log_analytics_workspace_id = module.law.id
name = "fn-recent-failures"
category = "Functions"
display_name = "RecentFailures(days)"
query = file("${path.module}/queries/recent-failures.kql")
function_alias = "RecentFailures"
function_parameters = ["days:int"]
}// Every alert rule and workbook can now just call it:
RecentFailures(7) | summarize count() by Resource💡 This is how you stop twelve alert rules each carrying its own copy of the same subtly-different join. Fix the logic once, in one place.
⚠️ And it is why the alias matters so much — see example 4.
3 · Every field is force-new, including tags
# Fixing a typo in a user-visible label:
~ resource "azurerm_log_analytics_saved_search" "this" {
~ display_name = "Failed sign-ins, last 24h" -> "Failed sign-ins (24h)" # forces replacement
}
Plan: 1 to add, 1 to destroy.
⚠️ The provider defines no update operation for this resource — it implements only Create, Read and Delete — so there is no in-place path for anything: not the query, not the category, not the display name, nottags. That is why the module'stimeoutsobject has noupdatekey.🔴 Passing one anyway produces NO error at all. Terraform's object-type conversion silently discards any key the type does not declare, so
timeouts = { update = "30m" }simply disappears. An earlier version of this README called it a type error; it is not, and this is the one wrong input on the module that gives no signal whatsoever.
⚠️ Before running a workspace-wide re-tagging exercise, note that it will destroy and recreate every saved search it touches. Harmless for a plain query; see example 4 for why it is not harmless for a function.
4 · A function alias is a published API Terraform cannot see
~ function_alias = "RecentFailures" -> "RecentFailuresV2" # forces replacement
⚠️ Every query, workbook and alert rule callingRecentFailures()breaks. Those callers are not Terraform resources — they are KQL text inside other resources, or saved elsewhere entirely — so they are absent from the dependency graph and nothing warns you.
💡 Treat a published alias as an interface: add
RecentFailuresV2alongside the old one, migrate callers, then remove the original.is_functionis emitted so a review can tell which saved searches carry that risk at all.
5 · Parameters require an alias
# ❌ Rejected at plan.
function_parameters = ["days:int"]
# function_alias omittedError: Invalid value for variable
function_parameters is only meaningful when function_alias is set. Without an alias
the saved search is not a function and the parameters bind to nothing, so this module
rejects the combination rather than storing something inert.
💡 The service silently ignores parameters on a non-function rather than rejecting them, so without this check you would get a resource that applies cleanly and quietly does not do what the configuration says.
ℹ️ The check lives on
function_parametersand readsfunction_aliasone-directionally. Terraform rejects validations that reference each other across variables, so splitting them would produce a cycle rather than a useful message.
6 · Parameter form is validated, types are not
function_parameters = ["days:int", "user:string"]# ❌ Rejected — no colon at all, so no parameter name.
function_parameters = ["days"]
# ✅ Accepted — the provider's schema regex allows all of these.
function_parameters = ["days:int=7"] # a default value
function_parameters = ["t:(*)"] # a wildcard tabular parameter
function_parameters = ["t:(user:string,count:long)"] # a tabular parameter, colons inside
function_parameters = ["prefix:string='a:b'"] # a default containing a colon
# ❌ Rejected by THIS MODULE, though the provider's regex allows it — see below.
function_parameters = ["a:int, b:string"]🔴 The module checks only the leading
name:, and that is deliberate. An earlier version required exactly one colon per entry (^[^:]+:[^:]+$) and therefore refused the tabular form and any default value containing a colon — legal input rejected, and, because a failedvalidation {}blocksterraform destroyas well as apply, a configuration that could not be torn down through the module. The provider's own schema regex is the authority here; it runs offline atterraform validateanyway, so the module's job is a clearer message on the one mistake the provider's regex reports opaquely.
⚠️ The one place this module IS stricter than the provider: an entry containing a comma followed by a space. The provider joins the whole list into a single API string with", "and splits it back on the same sequence when reading, so an entry holding several parameters is written as one element and read back as two — a diff that can never be resolved. Refusing it is right precisely because no configuration can converge with it."t:(a:int,b:string)"— comma, no space — round-trips cleanly.
⚠️ Changing a function's signature is force-new and a breaking change for callers, so it carries both hazards from examples 3 and 4 at once.
7 · Nothing validates the KQL
# ✅ Accepted. Applies cleanly. Fails when anyone runs it.
query = "SigninLogz | where Nonsense == 1"
⚠️ Neither Terraform nor the service checks the query at write time. A misspelled table, a renamed column, or a syntax error all become a saved search that exists and errors on use.
💡 Run the query in the workspace before committing it. That is the only real gate, and it is why the Prerequisites list KQL that has been run at least once.
8 · `name` and `display_name` are different
name = "failed-signins" # internal identifier
display_name = "Failed sign-ins, last 24h" # what people see in the portalℹ️ Both are force-new. Name the record for stability and the display name for humans — and accept that improving the wording later means a recreate.
9 · Use `category` as a real taxonomy
category = "Identity" # not "Queries", not "Misc"💡 A workspace accumulates saved searches quickly, and
categoryis the only thing keeping them findable in the portal. Agree a small set — Identity, Networking, Cost, Functions — and use it consistently.
⚠️ Force-new, so re-categorising later recreates the search.
10 · Several searches, keyed
locals {
searches = {
failed_signins = { category = "Identity", display = "Failed sign-ins (24h)", file = "failed-signins.kql" }
nsg_denies = { category = "Networking", display = "NSG denies (1h)", file = "nsg-denies.kql" }
top_spend = { category = "Cost", display = "Top ingesting tables", file = "top-spend.kql" }
}
}
module "saved_searches" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-saved-search.git?ref=v1.0.0"
for_each = local.searches
log_analytics_workspace_id = module.law.id
name = each.key
category = each.value.category
display_name = each.value.display
query = file("${path.module}/queries/${each.value.file}")
}💡 Keyed by a stable string, so adding a fourth search never re-indexes the first three — which matters more than usual here, because re-indexing on a force-new-everything resource means recreating them.
11 · When a query pack is the better shape
# This module: workspace-scoped, one search per module block.
module "failed_signins" {
source = ".../terraform-azurerm-log-analytics-saved-search.git?ref=v1.0.0"
log_analytics_workspace_id = module.law.id
category = "category"
display_name = "display-example"
name = "failed-signins-example"
query = "query"
}# The alternative: an RBAC-governed pack, shareable across workspaces.
module "ops_queries" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-query-pack.git?ref=v1.0.0"
location = "eastus2"
name = "ops-queries-example"
resource_group_name = "rg-example"
}💡 A saved search belongs to one workspace. A query pack is a separate resource with its own RBAC, designed to be shared across workspaces — the better shape for a curated library that several teams consume.
ℹ️ They are not mutually exclusive: functions tend to belong with the workspace whose tables they read, while general operational queries travel better in a pack.
12 · What a review should assert
output "saved_search_posture" {
value = {
for k, m in module.saved_searches : k => {
display = m.display_name
category = m.category
is_fn = m.is_function # true means other things may depend on it
alias = m.function_alias
}
}
}💡
is_functionis the field worth filtering on. A plain saved search can be changed freely — it recreates, and nothing else notices. A function cannot: its alias is an interface, and the callers are invisible to Terraform.
🔒 A useful policy rule: any diff touching a resource where
is_functionis true requires named sign-off, because the blast radius is outside the plan.
13 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-observability-prod"
location = "eastus"
}
module "law" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"
name = "law-observability-prod"
resource_group_name = module.rg.name
location = module.rg.location
}
# 1 · A shared FUNCTION — the reusable piece other things call.
module "recent_failures_fn" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-saved-search.git?ref=v1.0.0"
log_analytics_workspace_id = module.law.id
name = "fn-recent-failures"
category = "Functions"
display_name = "RecentFailures(days)"
query = file("${path.module}/queries/recent-failures.kql")
function_alias = "RecentFailures"
function_parameters = ["days:int"]
tags = {
interface = "published" # a marker: changing this alias breaks callers
}
}
# 2 · Ordinary operational queries, keyed so additions never re-index.
locals {
searches = {
failed_signins = { category = "Identity", display = "Failed sign-ins (24h)", file = "failed-signins.kql" }
nsg_denies = { category = "Networking", display = "NSG denies (1h)", file = "nsg-denies.kql" }
}
}
module "saved_searches" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-saved-search.git?ref=v1.0.0"
for_each = local.searches
log_analytics_workspace_id = module.law.id
name = each.key
category = each.value.category
display_name = each.value.display
query = file("${path.module}/queries/${each.value.file}")
}
output "observability_queries" {
value = {
published_function = module.recent_failures_fn.function_alias
functions = [for k, m in module.saved_searches : k if m.is_function] # expect []
searches = keys(module.saved_searches)
}
}🔒 What the composition gets right: every query lives in a reviewable
.kqlfile, the one published function is separated from the ordinary searches and tagged as an interface, and the keyed map means adding a search never recreates the others.
⚠️ What no plan will tell you: which alert rules callRecentFailures(). Those callers are KQL text, not Terraform resources — so before touching that alias, search the workspace, not the state file.
💡 Note the
tagson the function. It is a marker for humans, and worth having despite tags being force-new here — you set it once, at creation, alongside the alias it describes.
| Input | Type | Default | Notes |
|---|---|---|---|
log_analytics_workspace_id |
string |
— | Required. Force-new. Note the field name. |
name |
string |
— | Required. Force-new. Internal identifier. |
category |
string |
— | Required. Force-new. A real taxonomy. |
display_name |
string |
— | Required. Force-new. The portal label. |
query |
string |
— | Required. Force-new. Validated non-empty only. |
function_alias |
string |
null |
Force-new. |
function_parameters |
list(string) |
[] |
Force-new. Requires function_alias. Checked only for a leading name: — the provider accepts defaults, wildcards and tabular forms too. No ", " inside an entry. |
tags |
map(string) |
{} |
|
timeouts |
object(...) |
null |
create / read / delete — no update. |
Full schemas
variable "query" {
type = string
# Validated non-empty after trimming — which catches a wrong file() path resolving to "".
# ⚠️ Nothing validates the KQL itself. A wrong query applies cleanly and fails on use.
}
variable "function_alias" {
type = string
default = null
# 💡 Promotes the saved search into a reusable FUNCTION.
# ⚠️ Force-new, and once queries and alert rules call it the alias is a published API whose callers
# are NOT in Terraform's dependency graph — a rename breaks them silently.
}
variable "function_parameters" {
type = list(string)
default = []
# Refused without function_alias: without an alias the search is not a function, so the
# parameters bind to nothing. The check lives here and reads function_alias one-directionally,
# because two variables validating each other is a cycle.
#
# Entries are checked ONLY for a leading "name:" - the provider's own schema regex validates the
# rest, and it accepts more forms than "name:type" (defaults, wildcards, tabular parameters whose
# own columns carry colons). Being narrower than the provider here refuses legal input.
#
# Also refused: an entry containing ", ". The provider joins the list on that sequence and splits
# on it when reading, so such an entry cannot converge.
}
variable "tags" {
type = map(string)
default = {}
# ⚠️ Force-new like every other field: a tag edit destroys and recreates the saved search.
}| Output | Description | Sensitive |
|---|---|---|
id |
The saved search's Resource ID. | no |
name |
The internal record name. Force-new. | no |
display_name |
The portal label. Force-new. | no |
category |
The portal grouping. | no |
log_analytics_workspace_id |
The containing workspace. | no |
function_alias |
The alias other queries call, or null. |
no |
is_function |
Derived — whether other things may depend on this. | no |
workspace_name |
Parsed from the workspace ID; known at plan time. | composition |
function_parameter_count |
The function's arity - a change breaks callers. | change review |
function_parameter_names |
The caller-facing half of the signature, in order. | change review |
every_field_is_force_new_including_tags |
Always true - the provider has no update operation. |
change review |
nothing_validates_the_kql |
Always true - a bad query applies cleanly. |
review |
callers_of_a_function_are_invisible_to_terraform |
Always true. |
change review |
a_saved_search_is_inert_until_something_runs_it |
Always true. |
composition |
function_parameters_are_sent_as_one_joined_string |
Always true - hence the ", " refusal. |
authoring |
🔒 The query body is not emitted. It is already in the caller's files, and re-emitting it would put a long document into every consumer's state and plan output.
-
There is no update path at all, which is unusual enough to shape how the module is used. The provider implements only Create, Read and Delete, and every field is force-new including
tags— which come fromcommonschema.TagsForceNew()rather than the ordinary helper. So edits that feel trivial (a display-name typo, a category correction, a bulk re-tag) are destroy-and-create. Thetimeoutsobject omitsupdateto match, but note that a caller who supplies one anyway gets no error: object-type conversion silently discards undeclared keys. That is stated in the variable's own description because nothing else will report it. -
Function mode publishes an interface into a space Terraform cannot see. Callers are KQL text inside alert rules, workbooks and other queries — not Terraform resources — so they never appear in the dependency graph. That makes an alias rename a silent breaking change, and it is why
is_functionexists: so a review can tell which saved searches carry that risk before touching them. -
The parameter check exists because the service fails silently. Parameters on a non-function are ignored rather than rejected, which would otherwise produce a resource that applies cleanly and does not match its configuration. Catching it at plan is exactly the case this library's typing rules are for.
-
The KQL validation is deliberately modest and says so. Non-empty-after-trimming catches the realistic infrastructure failure (a bad
file()path) and nothing more. Claiming to validate the query would be worse than admitting the limit. -
The cross-field check is one-directional by necessity — Terraform rejects mutually-referencing validations with a cycle error rather than a useful message. Same pattern as the
linked-serviceandstorage-insightssiblings. -
categoryis load-bearing, not decoration. It is the portal's only organising mechanism for saved searches, and it is force-new, so it is worth agreeing a taxonomy before the first one is created. -
The workspace input name differs from its siblings.
log_analytics_workspace_idhere versusworkspace_idorworkspace_name+resource_group_nameelsewhere in this family. Documented because a copied line fails. -
For a cross-workspace library, a query pack is the better shape. This resource is workspace-scoped by design;
terraform-azurerm-log-analytics-query-packexists for the shared, RBAC-governed case.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Silently-ignored configuration | function_parameters without an alias rejected at plan |
— |
| Empty queries | rejected — catches a bad file() path |
— |
| KQL correctness | stated as unvalidatable rather than implied to be checked | — |
| Hidden dependencies | is_function emitted so the risky records are identifiable |
— |
| Destructive edits | every force-new field documented, and update omitted from timeouts |
— |
| Query bodies | never emitted into outputs or downstream state | — |
| Secrets | none accepted, none emitted | — |
- Before applying: run the KQL against the workspace. Nothing else will check it.
- Before applying: decide
name,display_nameandcategoryas if permanent, because they are. - Before renaming a
function_alias: search the workspace for callers. The state file will not have them. - Before a workspace-wide re-tag: note it recreates every saved search, functions included.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source to a tag —
?ref=v1.0.0— never a branch. - Plan-only from here. A human applies from CI.
⚠️ Every plan touching this resource is a replacement. That is normal here, and harmless for plain queries.⚠️ For a function, add the new alias alongside the old one, migrate callers, then remove the original.- ℹ️ Keep queries in
.kqlfiles so a diff shows the logic change, not a wall of escaped string.
terraform validate and terraform fmt -check are the offline gate. They confirm:
- the five required inputs are present and typed
string; queryis non-empty after trimming;function_parametersis empty unlessfunction_aliasis set; each entry begins withname:, and none contains a comma followed by a space;- the
timeoutsobject declares onlycreate,readanddelete—⚠️ but passingupdateis silently discarded, not an error, because object-type conversion drops undeclared keys; - the module declares no
providerblock.
What only plan and apply exercise:
- whether the workspace exists;
- whether the alias collides with an existing function in the workspace.
What no Terraform command checks at any stage:
- whether the KQL is valid, or names tables that exist — it applies cleanly and fails on use;
- which queries and alert rules call a function alias. They are not Terraform resources, so a rename passes every gate and breaks them anyway.
Outputs:
category = "Functions"
display_name = "RecentFailures(days)"
function_alias = "RecentFailures"
id = "/subscriptions/00000000-.../workspaces/law-observability-prod/savedSearches/fn-recent-failures"
is_function = true
log_analytics_workspace_id = "/subscriptions/00000000-.../workspaces/law-observability-prod"
name = "fn-recent-failures"
⚠️ is_function = trueis the signal that this record has dependants outside Terraform. Treat any diff on it accordingly.
| Symptom | Cause | Fix |
|---|---|---|
| Every change proposes a replacement | There is no update operation on this resource. | Expected (example 3). |
Plan rejects an update timeout |
Same reason — no update operation exists. | Remove the key. |
| A saved search exists but errors on use | The KQL is wrong or names a missing table. Nothing validated it. | Run the query in the workspace and fix the file (example 7). |
| A saved search exists and returns nothing at all | query may be empty — usually a wrong file() path. |
The non-empty check catches this at plan; verify the path. |
| Alert rules broke after a Terraform change | A function_alias was renamed or removed. |
Restore the old alias, migrate callers, then retire it (example 4). |
| Parameters seem to be ignored | They were set without function_alias. |
Rejected at plan by this module (example 5). |
terraform validate rejects a function_parameters entry |
Either it has no leading name:, or it contains a comma followed by a space. |
Give every entry a parameter name and a colon ("days:int"), and put each parameter in its own list element — the provider joins the list on ", ", so an entry containing that sequence cannot converge (example 6). |
A function_parameters entry the provider accepts is rejected by an older module version |
Versions before this one required exactly one colon, refusing the tabular form and colon-bearing defaults. | Upgrade. The current check verifies only the leading name:. |
| A bulk re-tag recreated every saved search | tags is force-new here. |
Expected; avoid bulk re-tagging this resource type casually. |
| Copied a workspace line from a sibling and it failed | This module uses log_analytics_workspace_id. |
Check the field name (see Provider / Versions). |
| Searches are impossible to find in the portal | category was used inconsistently. |
Agree a taxonomy; note re-categorising recreates them (example 9). |
| Wanted the queries shared across workspaces | A saved search belongs to one workspace. | Use terraform-azurerm-log-analytics-query-pack (example 11). |
azurerm_log_analytics_saved_search— provider documentation for this resource.azurerm_log_analytics_query_pack— the shareable, RBAC-governed alternative.- Log Analytics functions — what a
function_aliaspublishes, and how callers use it. - KQL quick reference — for writing the query this module stores.
- Sibling modules in this family:
terraform-azurerm-log-analytics-workspace,terraform-azurerm-log-analytics-query-pack,terraform-azurerm-log-analytics-workspace-table,terraform-azurerm-log-analytics-linked-service. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

