LA-CleanUp is a small, single-file PowerShell script (Invoke-LogicAppCleanup.ps1) that scans the currently-active Azure subscription, classifies every Consumption workflow as Idle or AlwaysFailing, optionally exports the candidate list to CSV, and walks you through per-item for deletion using Azure CLI.
If you’ve inherited an Azure subscription that’s been collecting Logic Apps Consumption workflows for a couple of years, you already know the shape of the problem: dozens of TestLA, webhook-test-2, poc-for-jira-FINAL workflows in the portal, half of them still Enabled, half of them quietly failing every five minutes against a connection that was rotated last quarter. They’re cheap, but they’re not free — polling triggers keep firing, alert rules keep paging, abandoned Microsoft.Web/connections keep pinning permissions, and the portal’s workflow list keeps getting harder to read.
Source + full docs: GitHub - dengyanbo/LA-CleanUp
What it gives you
- Two color-coded tables — Idle (no runs in the last
-IdleDaysdays; never-run workflows land here too) and AlwaysFailing (ran in the window, but not a singleSucceeded). - An optional, timestamped CSV (
LogicAppCleanup-Candidates-<yyyyMMdd-HHmmss>.csv) with a stable column set, easy to drop in a Teams channel or PR for owner review before you delete anything. - A per-item
y/N/qdeletion loop —ydeletes,N/Enter skips,qquits the loop without touching anything else. - A final summary with scanned / idle / always-failing / deleted / skipped / failed counts.
- Server-side OData
$filteron run history so the scan is fast even on a subscription with thousands of workflow runs in the window. - Lazy
Statelookup (Enabled/Disabled) — only fetched for actual candidates, not for every healthy workflow.
What it is not: it does not touch Logic Apps Standard (Microsoft.Web/sites with kind=workflowapp) — those store run history in storage tables and need a completely different tool (see LogicAppAdvancedTool). It also does not GC Microsoft.Web/connections, and it does not iterate subscriptions — one invocation, one subscription, deliberately.
Quick start
Prerequisites
- PowerShell 5.1+ (Windows PowerShell or PowerShell 7 — both fine).
- Azure CLI on PATH: https://aka.ms/azcli
- An interactive
az loginagainst the subscription that owns the Logic Apps. Logic App Contributor on the relevant RGs (or plain Contributor) is enough — you need list + delete onMicrosoft.Logic/workflows, plus the ARM token youraz loginalready grants.
Get the script
git clone https://github.com/dengyanbo/LA-CleanUp.git
cd LA-CleanUp
One-liner — defaults (90-day idle, 90-day failure window, current sub)
.\Invoke-LogicAppCleanup.ps1
A typical run looks like this:
[INFO] Active subscription: Contoso-Integration (1111-2222-...)
[INFO] Signed in as : alice@contoso.com
[INFO] Idle cutoff : runs older than 2026-02-20T03:08:00Z (>90 days)
[INFO] Failure window : checking runs since 2026-02-20T03:08:00Z (last 90 days)
[INFO] Listing Logic App (Consumption) workflows...
[ OK ] Found 13 workflow(s).
[ 1/13] order-processor (rg-integration)
[ 2/13] webhook-test (MyTest)
...
[ OK ] Scan complete. Idle: 9, AlwaysFailing: 1, Errors: 0
=== Idle Logic Apps (no runs in last 90 days) ===
Name ResourceGroup Location State LastStatus LastRunTime
---- ------------- -------- ----- ---------- -----------
webhook-test MyTest australiaeast Enabled Never
poc-for-jira rg-integration eastus Enabled Succeeded 2025-09-04T...
...
=== Always-Failing Logic Apps (no successful run in last 90 days) ===
Name ResourceGroup Location State LastStatus LastRunTime
---- ------------- -------- ----- ---------- -----------
nightly-export rg-integration eastus Enabled Failed 2026-05-20T...
Export the 10 candidate(s) to CSV? (y/N): y
[ OK ] CSV written: ...\LogicAppCleanup-Candidates-20260521-110800.csv
Starting per-item deletion review. Answer y to delete, N (or Enter) to skip, q to quit.
(1/10) Delete [Idle] webhook-test in MyTest ? (y/N/q): y
[INFO] Deleting webhook-test ...
[ OK ] Deleted: webhook-test
(2/10) Delete [Idle] poc-for-jira in rg-integration ? (y/N/q): N
Skipped.
...
=== Summary ===
Scanned : 13
Idle : 9
Always-failing : 1
Scan errors : 0
Deleted : 4
Skipped : 6
Delete failures : 0
That’s the whole workflow.
The commands you’ll actually use
1. Scope to one resource group with a tighter idle threshold
.\Invoke-LogicAppCleanup.ps1 -IdleDays 60 -ResourceGroup rg-integration
2. Only review always-failing apps (skip the idle pile)
.\Invoke-LogicAppCleanup.ps1 -SkipIdle
3. Only review idle apps (skip always-failing)
.\Invoke-LogicAppCleanup.ps1 -SkipAlwaysFailing
4. Incident-driven cleanup — tight window, focused RG
.\Invoke-LogicAppCleanup.ps1 -IdleDays 30 -FailureWindowDays 7 `
-ResourceGroup rg-integration-prod
This answers: “Within prod-integration, which workflows haven’t run in a month, and which have been failing all week?”
5. Dry-run (there is no -WhatIf)
The per-item y/N/q prompt is the safety model. To dry-run, just answer N to every prompt — or q at the first one. The CSV is still written before the deletion loop starts, so you walk away with the report and zero deletions.
Parameters — cheat sheet
| Parameter | Default | Meaning |
|---|---|---|
-IdleDays | 90 | No runs in this many days ⇒ flagged Idle. Never-run workflows land here too. |
-FailureWindowDays | 90 | Ran in this window but no Succeeded ⇒ flagged AlwaysFailing. |
-ResourceGroup | (none) | Restrict the scan to a single RG. |
-SkipIdle | switch | Skip the Idle bucket entirely. |
-SkipAlwaysFailing | switch | Skip the AlwaysFailing bucket entirely. |
Why Invoke-RestMethod instead of az rest
This is the gotcha I want every other Logic Apps scripter to know about, because it eats hours.
The Logic Apps Management REST API is queried with OData — $top, $filter, the usual:
GET /subscriptions/.../workflows/{wf}/runs
?api-version=2016-06-01
&$top=1
&$filter=startTime ge 2026-02-20T03:08:00Z and status eq 'Succeeded'
On Windows PowerShell, az is a .cmd shim. The URL you pass to az rest --uri "..." is re-parsed by cmd.exe, and the & characters separating OData query parameters get interpreted as command separators. Symptoms range from '$filter' is not recognized as an internal or external command to silent wrong-page returns where the filter is just dropped and you get the most recent N runs instead. Quoting heroics — single quotes, double quotes, triple-escaped ^& — don’t fully solve it across PS 5.1 and PS 7.
The fix is to skip az rest entirely. The script grabs an ARM token once with az account get-access-token, caches it on a script-scoped variable, refreshes it proactively at the 45-minute mark (ARM tokens last ~60), and calls Invoke-RestMethod directly:
$tok = az account get-access-token --resource 'https://management.azure.com/' -o json |
ConvertFrom-Json
Invoke-RestMethod -Method Get -Uri $url `
-Headers @{ Authorization = "Bearer $($tok.accessToken)" }
That bypasses cmd.exe entirely. Bonus: caching the token makes the script noticeably faster on subscriptions with many candidates, since we’re no longer shelling out to az for every REST call.
Why $top=1 matters more than you’d think
The script never pages run history. For each surviving workflow it asks two existence questions:
?api-version=2016-06-01&$top=1&$filter=startTime ge <cutoff>
?api-version=2016-06-01&$top=1&$filter=startTime ge <cutoff> and status eq 'Succeeded'
If the first one returns zero rows ⇒ the workflow is Idle. Otherwise, if the second one returns zero rows ⇒ AlwaysFailing. Both queries are O(1) server-side because startTime is indexed and the response is capped at one row. Even on a chatty workflow with tens of thousands of runs in the window, the answer comes back in tens of milliseconds.
This is also why the script can afford to be sequential per workflow — there’s no need for parallelism when each existence check is essentially free.
Why “AlwaysFailing” is defined the way it is
The bucket is “ran in the window, but not a single Succeeded run in the window” — not “all runs are Failed”. That distinction matters:
- Workflows whose runs are
Running,Waiting, orCancelledbut neverSucceededget classified as AlwaysFailing. That’s usually what you want — a workflow that has been “Waiting” for 30 days is just as broken as one that’s been failing for 30 days. - Long-running workflows that legitimately haven’t completed yet look the same to the classifier. If you operate that kind of workflow, widen
-FailureWindowDaysso a slow but eventually-Succeeded run shows up in the window.
The reported LastStatus column in the table is the status of the most recent run, so you can usually eyeball the difference between “failing” and “still running.”
A field-tested rollout
If you’re running this on a subscription you don’t fully own, this multi-pass rollout has worked well:
- Run with
-SkipAlwaysFailingfirst. Idle workflows are the safe pile — if they haven’t done anything in 90+ days, deleting them rarely surprises anyone. - Export the CSV. Don’t delete yet — answer
qat the first delete prompt. - Drop the CSV in a Teams channel or PR. Give owners a few days to object.
- Re-run and actually delete the ones nobody claimed.
- Then run with
-SkipIdlefor the AlwaysFailing bucket. These often have an owner who just hasn’t noticed the breakage — treat the first pass as a bug-bash list, not a delete list.
Things to know before you run it
- Consumption only. Logic Apps Standard is out of scope — different model, different APIs, run history in storage tables. Use LogicAppAdvancedTool for those.
- No
-Force, no-WhatIf. The per-itemy/N/qprompt is the entire safety model. That’s deliberate — cleanup tools that take a-Forceget used with-Force. - One subscription per invocation. The script operates on whatever
az account showreturns. Useaz account set --subscription <id>to switch deliberately. Microsoft.Web/connectionsare not deleted. API connections are typically shared; the script intentionally leaves them alone. GC them with a separate pass.- Run history disappears with the workflow. Once you delete the Logic App, Azure removes its run history too. Export the CSV first if you want any record.
- Enabled vs. Disabled is reported, not enforced. A
Disabledworkflow can still be Idle. The script shows the state in the table so you can decide.
Where to go next
The GitHub repo has the full reference — parameter table, CSV schema, the “how it works” deep dive on lazy State fetch, bearer-token caching, server-side $filter, considerations and limitations, and the recommended rollout:
👉 https://github.com/dengyanbo/LA-CleanUp
Issues and PRs welcome. A few directions on the roadmap: an optional disable-instead-of-delete mode for the first pass (PATCH ...?api-version=2019-05-01 with properties.state = 'Disabled'), a cross-subscription mode that iterates az account list with a confirmation per sub, and a companion API-connection GC script that uses Resource Graph to join connections against workflow definitions in one query.