<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>luke.geek.nz Blog</title>
        <link>https://luke.geek.nz/</link>
        <description>luke.geek.nz Blog</description>
        <lastBuildDate>Thu, 13 Aug 2026 03:16:54 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>Copyright © 2026 luke.geek.nz.</copyright>
        <item>
            <title><![CDATA[Deploy Azure Monitor SLI Alerting as Code]]></title>
            <link>https://luke.geek.nz/azure/monitor-sli-alerting-as-code/</link>
            <guid>https://luke.geek.nz/azure/monitor-sli-alerting-as-code/</guid>
            <pubDate>Thu, 13 Aug 2026 03:16:54 GMT</pubDate>
            <description><![CDATA[The SLI resource has one boolean for alerting. The actual configuration lives somewhere else entirely, and once you know where, the whole thing is IaC.]]></description>
            <content:encoded><![CDATA[<p>I spent the better part of a day convinced that <a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Monitor SLI</a> alerting could not be deployed as infrastructure as code. I wrote a parallel implementation to work around it.</p>
<p>I was wrong, and the way I was wrong is the useful part.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-trap">The trap<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-trap" class="hash-link" aria-label="Direct link to The trap" title="Direct link to The trap" translate="no">​</a></h2>
<p>You create an SLI on a service group, and you want it to alert. So you read the API. <code>Microsoft.Monitor/slis</code> at <code>2025-03-01-preview</code> gives you exactly one alerting property:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">"enableAlert": true</span><br></div></code></pre></div></div>
<p>That is the whole surface. No threshold, no lookback window, no severity, and no action group. I checked the TypeSpec in <a href="https://github.com/Azure/azure-rest-api-specs" target="_blank" rel="noopener noreferrer" class=""><code>azure-rest-api-specs</code></a>, all seven files. I checked every API version the resource provider reports. There is only one. I checked whether a newer version existed. It does not.</p>
<p>Then I set <code>enableAlert: true</code> on a badly breaching SLI, with attainment at 79.9% against a 99% target and the error budget long since exhausted, and waited. No alert appeared in Alerts Management after fifteen minutes.</p>
<p>I was starting to think that the feature was portal-only and could not be expressed as code, which was weird as the portal is essentially an ARM (Azure Resource Manager) client. That conclusion was wrong.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-the-portal-actually-does">What the portal actually does<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#what-the-portal-actually-does" class="hash-link" aria-label="Direct link to What the portal actually does" title="Direct link to What the portal actually does" translate="no">​</a></h2>
<p>The portal's Baseline + Alert tab offers fast burn rate, slow burn rate, and action group selection. Since the SLI API has none of those fields, I captured what the portal sends when you select Save.</p>
<p>It updates the SLI, at the same API version I was already using, but only to flip <code>enableAlert</code>. Then it creates three ordinary <code>Microsoft.Insights/metricAlerts</code> resources in the workload resource group:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">&lt;sliName&gt; baseline alert</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">&lt;sliName&gt; fast burn alert</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">&lt;sliName&gt; slow burn alert</span><br></div></code></pre></div></div>
<p>Those resources use <code>api-version=2024-03-01-preview</code>, with <code>odata.type: Microsoft.Azure.Monitor.PromQLCriteria</code>. They scope the query to the Azure Monitor Workspace (<code>targetResourceType: microsoft.monitor/accounts</code>), not to the SLI.</p>
<!-- -->
<p><code>enableAlert</code> is a display flag. The alerts are the mechanism, and they are entirely ordinary ARM resources. Nothing private is involved. The undocumented part is the relationship between the two resource types.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-queries">The queries<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-queries" class="hash-link" aria-label="Direct link to The queries" title="Direct link to The queries" translate="no">​</a></h2>
<p>The alerts read the SLI's own emitted metrics. The SLI lists them under <code>destinationMetrics</code> as <code>&lt;sli&gt;:Good</code>, <code>&lt;sli&gt;:Total</code>, and <code>&lt;sli&gt;:Value</code>.</p>
<p>For a request-based SLI, the baseline rule checks attainment against the target:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") ({"availability:good"})</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  /</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") ({"availability:total"})</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) * 100 &lt; 99</span><br></div></code></pre></div></div>
<p>The burn-rate rule divides the observed error ratio by the error budget:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  (</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") (increase({"availability:total"}[15m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    -</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") (increase({"availability:good"}[15m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  /</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  (</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") (increase({"availability:total"}[15m])) * (1 - 0.99)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) &gt; 14</span><br></div></code></pre></div></div>
<p>Two details are easy to get wrong.</p>
<p>First, the correct function is <code>increase()</code>, not <code>sum_over_time()</code>. This corrects what I originally put in the post. The SLI publishes <code>:good</code> and <code>:total</code> as cumulative totals over the compliance window. They are not counters in the reset-on-restart sense, but they are monotonically increasing.</p>
<p>Early on, <code>rate()</code> against a freshly created SLI returned an empty result. I concluded from that single data point that the series was incompatible with the rate family of PromQL functions. It was not. The likely cause was too few samples. Rate-family functions need at least two samples spanning enough of the window to extrapolate from, and a new SLI cannot provide that.</p>
<p>After the series had been running for hours, <code>rate()</code> and <code>increase()</code> returned normal, consistent values. <code>sum_over_time(x[15m])</code> sums fifteen cumulative snapshots of an ever-growing total. That gives you a lookback-averaged cumulative ratio, not the error rate during those fifteen minutes.</p>
<p>Measured live, a total outage held burn rate at about 3.5 against a 14x threshold for more than five minutes. A fault confined to one SLI partition took 49 minutes to cross the same threshold. With <code>increase()</code>, the replay crossed at T+5 minutes, and a fresh live fault fired the alert at 10m06s end to end. That includes the mandatory <code>for: PT5M</code> sustained-condition window after the metric crossed the threshold.</p>
<p>If a PromQL function returns an empty result, that means the query has insufficient data at that point. It does not mean the data shape is incompatible. Retest after the series has real volume.</p>
<p>Second, <code>sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE")</code> is deliberate. The label does not exist, so the aggregation removes no real dimensions. A partitioned SLI still alerts per partition. It is the portal's own idiom, and copying it avoids changing the alert's scope accidentally.</p>
<p>The metric names also need quoted-selector syntax: <code>{"availability:good"}</code> rather than a bare identifier, because the names contain <code>:</code> and <code>-</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="burnrate-exists-but-is-not-published"><code>Burnrate</code> exists, but is not published<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#burnrate-exists-but-is-not-published" class="hash-link" aria-label="Direct link to burnrate-exists-but-is-not-published" title="Direct link to burnrate-exists-but-is-not-published" translate="no">​</a></h2>
<p>Partway through the investigation I found <code>Burnrate</code> declared as a first-class sampling type in <code>kqlmQueryResult.tsp</code>, alongside <code>Good</code>, <code>Total</code>, <code>Uptime</code>, <code>Downtime</code>, and <code>Value</code>. That looked like the answer. The service computes burn rate itself, so surely there must be a metric to alert on.</p>
<p>There is not.</p>
<p><code>kqlmQueryResult.tsp</code> describes the SLI query result model, not the metric contract. The live workspace publishes <code>:good</code>, <code>:total</code>, and <code>:value</code> for request-based SLIs, plus <code>:uptime</code>, <code>:downtime</code>, and <code>:value</code> for window-based SLIs. It does not publish <code>:burnrate</code>. Even with <code>enableAlert: true</code>, <code>destinationMetrics</code> lists only Good, Total, and Value.</p>
<p>The service computes burn rate internally and exposes it through its query surface, but does not publish it as a metric that an alert can read. The alert rules therefore calculate burn rate from <code>:good</code> and <code>:total</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="four-errors-between-here-and-a-working-deployment">Four errors between here and a working deployment<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#four-errors-between-here-and-a-working-deployment" class="hash-link" aria-label="Direct link to Four errors between here and a working deployment" title="Direct link to Four errors between here and a working deployment" translate="no">​</a></h2>
<p>Each of these took a deployment cycle to find.</p>
<p><code>The template language function 'mul' expects its first parameter to be of type 'Integer'.</code></p>
<p>I was calculating the baseline threshold as <code>json(sloTarget) * 100</code> to turn <code>0.99</code> into <code>99</code>. ARM's <code>mul</code> is integer-only. Carry the percentage as its own parameter.</p>
<p><code>Query-based alert rule doesn't support 'global' location.</code></p>
<p>Classic metric alerts are global resources, so every example you have copied probably sets <code>location: 'global'</code>. A PromQL-based alert is regional. Set <code>location</code> to the workload's region.</p>
<p><code>Query-based metric alert rules need a managed identity. Make sure it's configured.</code></p>
<p>Add <code>identity: { type: 'SystemAssigned' }</code>.</p>
<p>Then, silently, nothing evaluates. That identity needs Monitoring Data Reader (<code>b0d8363b-8ddd-447d-831f-62ca05bff136</code>) on the Azure Monitor Workspace it queries. The rule deploys cleanly without the role assignment.</p>
<div class="theme-admonition theme-admonition-danger admonition_xJq3 alert alert--danger"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M5.05.31c.81 2.17.41 3.38-.52 4.31C3.55 5.67 1.98 6.45.9 7.98c-1.45 2.05-1.7 6.53 3.53 7.7-2.2-1.16-2.67-4.52-.3-6.61-.61 2.03.53 3.33 1.94 2.86 1.39-.47 2.3.53 2.27 1.67-.02.78-.31 1.44-1.13 1.81 3.42-.59 4.78-3.42 4.78-5.56 0-2.84-2.53-3.22-1.25-5.61-1.52.13-2.03 1.13-1.89 2.75.09 1.08-1.02 1.8-1.86 1.33-.67-.41-.66-1.19-.06-1.78C8.18 5.31 8.68 2.45 5.05.32L5.03.3l.02.01z"></path></svg></span>danger</div><div class="admonitionContent_BuS1"><p>The managed identity and its Monitoring Data Reader role are part of the alert's working configuration. A successful ARM deployment does not prove that the query can evaluate.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-bicep">The Bicep<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-bicep" class="hash-link" aria-label="Direct link to The Bicep" title="Direct link to The Bicep" translate="no">​</a></h2>
<p>This is the important part of the resource. The full implementation is in <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/infra/modules/sli-native-alerts.bicep" target="_blank" rel="noopener noreferrer" class=""><code>infra/modules/sli-native-alerts.bicep</code></a>.</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">resource</span><span class="token plain"> fastBurnAlert </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Insights/metricAlerts@2024-03-01-preview'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">sliName</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)"> fast burn alert'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">location</span><span class="token operator">:</span><span class="token plain"> location          </span><span class="token comment" style="color:rgb(98, 114, 164)">// NOT 'global'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">identity</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">type</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'SystemAssigned'</span><span class="token plain">    </span><span class="token comment" style="color:rgb(98, 114, 164)">// required, and needs Monitoring Data Reader on the AMW</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">properties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">enabled</span><span class="token operator">:</span><span class="token plain"> </span><span class="token boolean">true</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">severity</span><span class="token operator">:</span><span class="token plain"> </span><span class="token number">1</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">targetResourceType</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'microsoft.monitor/accounts'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">scopes</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"> azureMonitorWorkspaceResourceId </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">evaluationFrequency</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'PT1M'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">criteria</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">'odata.type'</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Azure.Monitor.PromQLCriteria'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">allOf</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'SliAlertCriterion'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">query</span><span class="token operator">:</span><span class="token plain"> burnRateQuery</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">criterionType</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'StaticThresholdCriterion'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">failingPeriods</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">for</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'PT5M'</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">actions</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> actionGroupId</span><span class="token operator">:</span><span class="token plain"> pageActionGroupResourceId </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">customProperties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">serviceGroupId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">sliId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;/providers/Microsoft.Monitor/slis/</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">sliName</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">alertKind</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'fast-burn-rate'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">burnRate</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'14'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">lookback</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'15m'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p><code>customProperties</code> is not decoration. The portal uses that shape to reconstruct its Baseline + Alert UI from the alert resources. If you want alerts deployed from Bicep to render correctly in the SLI blade, reproduce the shape.</p>
<p>The complete relationship looks like this:</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-the-native-path-matters">Why the native path matters<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#why-the-native-path-matters" class="hash-link" aria-label="Direct link to Why the native path matters" title="Direct link to Why the native path matters" translate="no">​</a></h2>
<p>Because I believed SLI alerting was not IaC-able, I built multi-window burn-rate alerting myself in <code>Microsoft.AlertsManagement/prometheusRuleGroups</code>, recomputing the error ratio from raw application counters.</p>
<p>That parallel implementation drifted in the dangerous direction.</p>
<p>My PromQL summed across all three service tiers, so one user request counted once per hop and inflated the denominator roughly threefold. It also left <code>/healthz</code> and <code>/readyz</code> in the denominator, even though those endpoints never fail. Both errors pushed the ratio down, making the alert less sensitive than the SLO it was meant to enforce.</p>
<p>Neither object looked wrong in isolation. The SLI was correct. The alert rule was syntactically valid and fired in testing, because the test fault was a total outage and a total outage trips almost anything. I found the problem by measuring detection latency before and after correcting the selectors. The same outage went from 9m47s to 5m20s.</p>
<p>Alerts derived from the SLI's own published output cannot drift from the SLO they enforce. There is no second implementation to disagree with. That is a stronger property than saving a few lines of Bicep.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-id-tell-microsoft">What I'd tell Microsoft<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#what-id-tell-microsoft" class="hash-link" aria-label="Direct link to What I'd tell Microsoft" title="Direct link to What I'd tell Microsoft" translate="no">​</a></h2>
<p>The capability is good. The burn-rate formula is right, the multi-window design is right, and the portal experience is clean.</p>
<p>The gap is that nothing points from the SLI resource to the alert resources. The <code>enableAlert</code> boolean actively misleads: it looks like the alerting switch, and setting it does nothing observable by itself.</p>
<p>A line in the SLI documentation saying that alert configuration is stored as <code>Microsoft.Insights/metricAlerts</code> resources with <code>PromQLCriteria</code> would have saved me a day and a wrong-headed workaround. The four deployment requirements would also make a useful quickstart:</p>
<ul>
<li class="">ARM <code>mul</code> expects an integer</li>
<li class="">PromQL metric alerts need the workload's regional location</li>
<li class="">PromQL metric alerts need a managed identity</li>
<li class="">That identity needs Monitoring Data Reader on the workspace</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-lesson-id-keep">The lesson I'd keep<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#the-lesson-id-keep" class="hash-link" aria-label="Direct link to The lesson I'd keep" title="Direct link to The lesson I'd keep" translate="no">​</a></h2>
<p>I read the spec, tested the one field it exposed, and concluded that a feature did not exist. The spec was accurate. My inference from it was not.</p>
<p>When a portal can do something an API apparently cannot, the portal is still an ARM client. Twenty seconds in the network tab would have told me what a day of reading TypeSpec did not.</p>
<p>Check what the product does before concluding what it cannot.</p>
<p>The full Bicep for this alerting layer, and the rest of the showcase it belongs to, is public in <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p>
<p>Hopefully this article saves you the day I spent looking in the wrong resource type.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">AzureSLI-AzureSREAgent repository</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/infra/modules/sli-native-alerts.bicep" target="_blank" rel="noopener noreferrer" class="">Native SLI alert implementation</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent#architecture" target="_blank" rel="noopener noreferrer" class="">AzureSLI-AzureSREAgent architecture and reliability chain</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/docs/demo-runbook.md" target="_blank" rel="noopener noreferrer" class="">AzureSLI-AzureSREAgent deployment and demo runbook</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent/blob/main/docs/why-slos-not-just-alerts.md" target="_blank" rel="noopener noreferrer" class="">Why SLOs are not just alerts</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Azure Monitor SLIs on Managed Prometheus: the bits the docs don't tell you]]></title>
            <link>https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/</link>
            <guid>https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/</guid>
            <pubDate>Thu, 13 Aug 2026 02:58:13 GMT</pubDate>
            <description><![CDATA[Root causes and workarounds for creating Azure Monitor Service Level Indicators against Managed Prometheus metrics with Bicep and the ARM API.]]></description>
            <content:encoded><![CDATA[<p>Azure Monitor now does SLIs and SLOs natively, with error budgets and burn rate alerting, which is good news if you have been hand-rolling multi-window burn rate rules out of metric alerts for years. I have been building a demo that pairs Azure Monitor SLIs with the Azure SRE Agent on AKS, all deployed with the Azure Developer CLI, and I wanted every part of it in infrastructure as code.</p>
<p>Getting the infrastructure up was the easy part. Getting a single SLI created against Managed Prometheus metrics took considerably longer, and almost none of the reasons were in the documentation. This post is the list of things I wish I had known before I started, in the order they bit me.</p>
<p>If you only read one line: <strong>the metric namespace for Managed Prometheus metrics is <code>customdefault</code>, not <code>prometheus</code>.</strong> That one value cost me the most time by a wide margin.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-setup">The setup<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#the-setup" class="hash-link" aria-label="Direct link to The setup" title="Direct link to The setup" translate="no">​</a></h2>
<p>A three-tier app on AKS, instrumented with the Prometheus client, scraped by Managed Prometheus into an Azure Monitor Workspace (AMW). The SLI reads <code>http_requests_total</code> and compares good requests (status code below 500) against total requests. Standard availability SLI, nothing exotic.</p>
<p>Worth knowing up front, because it shapes everything else:</p>
<ul>
<li class="">An SLI is an <strong>extension resource on a tenant-scoped service group</strong>, at <code>/providers/Microsoft.Management/serviceGroups/{sg}/providers/Microsoft.Monitor/slis/{name}</code>. That is not the scope an <code>azd</code> Bicep deployment targets, so in practice you apply SLIs with a direct ARM call rather than from your main template.</li>
<li class="">The resource type is <code>Microsoft.Monitor/slis</code>, and at the time of writing the only published API version is <code>2025-03-01-preview</code>, even though the SLI/SLO feature itself is GA.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-1-the-metric-namespace-is-customdefault">Gotcha 1: the metric namespace is <code>customdefault</code><a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-1-the-metric-namespace-is-customdefault" class="hash-link" aria-label="Direct link to gotcha-1-the-metric-namespace-is-customdefault" title="Direct link to gotcha-1-the-metric-namespace-is-customdefault" translate="no">​</a></h2>
<p>This is the big one. Every Microsoft example I could find uses an MDM style metric namespace, and the obvious guess for Managed Prometheus metrics is <code>prometheus</code>, especially since the generated query text shows <code>metricNamespace("prometheus")</code> quite happily when you supply it.</p>
<p>Here is the trap. With <code>prometheus</code>, the metric name resolves without complaint, so you get no "metric not found" error. What you get instead is one of these two, depending on whether you declared any dimensions:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">QueryErrorCE2001: Name 'service' does not exist in current context</span><br></div></code></pre></div></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">NoPartitioningDimension: Query metadata returned no partitioning dimension,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">for account: mac_..., metricName: http_requests_total, metricNamespace: prometheus</span><br></div></code></pre></div></div>
<p>Both of those point you at dimensions, which is where I spent hours. I checked the Prometheus endpoint and confirmed all 17 labels were present and queryable. I checked the portal, and the SLI blade happily listed <code>service</code>, <code>namespace</code>, <code>status_code</code>, <code>path</code>, and the rest in its dimension picker. Everything said the dimensions existed, and the API insisted they did not.</p>
<p>The dimensions were fine. The namespace was wrong.</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"signalSourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"A"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"metricNamespace"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"customdefault"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"metricName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http_requests_total"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"sourceAmwAccountResourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"/subscriptions/.../providers/microsoft.monitor/accounts/&lt;amw&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"sourceAmwAccountManagedIdentity"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"/subscriptions/.../userAssignedIdentities/&lt;identity&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"filters"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"dimensionName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"status_code"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"operator"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"notstartswith"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"value"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"5"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"spatialAggregation"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"type"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"Sum"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"dimensions"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"service"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"temporalAggregation"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"type"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"Average"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>Change <code>prometheus</code> to <code>customdefault</code> and the same request that had been failing for hours returns <code>provisioningState: Succeeded</code>.</p>
<p>Note also that an SLI needs <strong>at least one partitioning dimension</strong>. An empty <code>dimensions</code> array fails with <code>NoPartitioningDimension</code>, so pick something meaningful (I use <code>service</code>).</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-2-the-identity-roles-go-on-the-dcr-not-the-workspace">Gotcha 2: the identity roles go on the DCR, not the workspace<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-2-the-identity-roles-go-on-the-dcr-not-the-workspace" class="hash-link" aria-label="Direct link to Gotcha 2: the identity roles go on the DCR, not the workspace" title="Direct link to Gotcha 2: the identity roles go on the DCR, not the workspace" translate="no">​</a></h2>
<p>The documented prerequisites say the SLI identity needs <strong>Monitoring Reader</strong> on the source workspace, and <strong>Monitoring Reader</strong> plus <strong>Monitoring Metrics Publisher</strong> on the destination workspace's default data collection rule (DCR).</p>
<p>I read that, and assigned both roles on the Azure Monitor Workspace resource. That looks right, it passes review, and it fails at SLI creation with:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">[DestinationAmwAccountAccessValidator] Access denied to target resource</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">/subscriptions/.../resourceGroups/MA_&lt;amw&gt;_&lt;region&gt;_managed/providers/Microsoft.Insights/dataCollectionRules/&lt;amw&gt;</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">for identity /subscriptions/.../userAssignedIdentities/&lt;identity&gt;</span><br></div></code></pre></div></div>
<p>The default DCR is not in your resource group. It lives in the workspace's <strong>managed</strong> resource group, named <code>MA_&lt;amw-name&gt;_&lt;region&gt;_managed</code>, and it shares the workspace's name.</p>
<p><img decoding="async" loading="lazy" alt="Where the SLI identity&amp;#39;s roles actually need to go: assigning Monitoring Reader and Monitoring Metrics Publisher to the Azure Monitor Workspace resource itself fails SLI creation, the roles need to go on the default DCR inside Azure&amp;#39;s own automatically-created managed resource group instead" src="https://luke.geek.nz/assets/images/gotcha2-dcr-rbac-7f4c56d63188e292c3d3296b96a970c2.svg" width="1166" height="487" class="img_ev3q"></p>
<p>In Bicep you need a separate module scoped to that resource group:</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">module</span><span class="token plain"> sliDcrRbac </span><span class="token string" style="color:rgb(255, 121, 198)">'modules/sli-dcr-rbac.bicep'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">scope</span><span class="token operator">:</span><span class="token plain"> az</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">resourceGroup</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'MA_</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">amwName</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">_</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">location</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">_managed'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'sli-dcr-rbac'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">params</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">azureMonitorWorkspaceName</span><span class="token operator">:</span><span class="token plain"> amwName</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">sliIdentityPrincipalId</span><span class="token operator">:</span><span class="token plain"> sliIdentity</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">outputs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">principalId</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>with the DCR referenced as <code>existing</code> inside that module, and role assignments scoped to it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-3-the-preview-call-is-useless-for-debugging-the-create-call-is-excellent">Gotcha 3: the preview call is useless for debugging, the create call is excellent<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-3-the-preview-call-is-useless-for-debugging-the-create-call-is-excellent" class="hash-link" aria-label="Direct link to Gotcha 3: the preview call is useless for debugging, the create call is excellent" title="Direct link to Gotcha 3: the preview call is useless for debugging, the create call is excellent" translate="no">​</a></h2>
<p>There is a <code>sliSignalPreview</code> action that the portal's <strong>Validate</strong> button uses. It is tempting to gate your deployment on it, which is exactly what I did, and it was a mistake.</p>
<p>For essentially every failure, <code>sliSignalPreview</code> returns this:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"code"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"MalformedStructureError"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"message"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"The request properties payload is not in the correct format."</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>No detail, no field name, nothing. Meanwhile the actual create call (<code>PUT .../slis/{name}</code>) returns the generated query, the validator that rejected it, and the offending identifier by name. Same inputs, completely different diagnostic value.</p>
<p>So do not gate creation on a successful preview. Attempt the create and read what comes back. My script now runs the preview as advisory output only.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-4-the-real-error-is-often-in-the-portal-notification-pane">Gotcha 4: the real error is often in the portal notification pane<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-4-the-real-error-is-often-in-the-portal-notification-pane" class="hash-link" aria-label="Direct link to Gotcha 4: the real error is often in the portal notification pane" title="Direct link to Gotcha 4: the real error is often in the portal notification pane" translate="no">​</a></h2>
<p>SLI creation runs several validators in sequence, and each one masks the next. When it fails in the portal, the blade shows a generic validation banner and resets the wizard back to Basics, which tells you nothing.</p>
<p>The full error, including the validator name, shows up in the <strong>notification pane</strong> (the bell icon). The <code>DestinationAmwAccountAccessValidator</code> failure above was visible nowhere else: not in the blade, and not in the response to the preview call.</p>
<p>If both your API calls and the portal appear to fail identically, check the notifications before you conclude anything about the platform. I twice decided this was a platform limitation and twice I was wrong.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-5-prerequisites-that-fail-as-generic-errors">Gotcha 5: prerequisites that fail as generic errors<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-5-prerequisites-that-fail-as-generic-errors" class="hash-link" aria-label="Direct link to Gotcha 5: prerequisites that fail as generic errors" title="Direct link to Gotcha 5: prerequisites that fail as generic errors" translate="no">​</a></h2>
<p>Two more that produce contentless errors rather than saying what is missing.</p>
<p><strong>The service group needs a default Azure Monitor Workspace.</strong> Until Monitor settings are configured on the service group, every SLI call fails. The portal path is service group, then Monitoring, then Monitor settings. Microsoft documents the subscription scoped association:</p>
<div class="language-http codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-http codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT https://management.azure.com/subscriptions/&lt;sub&gt;/providers/microsoft.monitor/settings/default?api-version=2025-06-03-preview</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">{ "properties": { "defaultAzureMonitorWorkspace": "&lt;amw resource id&gt;" } }</span><br></div></code></pre></div></div>
<p>What is not documented, but works, is the same settings resource at <strong>service group scope</strong>:</p>
<div class="language-http codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-http codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT https://management.azure.com/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;/providers/microsoft.monitor/settings/default?api-version=2025-06-03-preview</span><br></div></code></pre></div></div>
<p>That one returns a proper resource with an etag, and it means you can keep the whole thing in code instead of clicking through the portal. Treat it as undocumented and re-check it on each new API version.</p>
<p><strong>The metrics have to exist first.</strong> An SLI requires its input metric and its partitioning dimensions to already be present in Managed Prometheus at creation time. Create the SLI before the series materialises and validation fails. In a fresh environment that is a real wait: allow for the scrape interval plus two to three minutes of ingestion lag before you try. My deployment script polls the Prometheus endpoint for the metric name and only then creates the SLI.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-6-enablealert-looks-like-the-switch-and-it-lies">Gotcha 6: <code>enableAlert</code> looks like the switch, and it lies<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-6-enablealert-looks-like-the-switch-and-it-lies" class="hash-link" aria-label="Direct link to gotcha-6-enablealert-looks-like-the-switch-and-it-lies" title="Direct link to gotcha-6-enablealert-looks-like-the-switch-and-it-lies" translate="no">​</a></h2>
<p>At <code>2025-03-01-preview</code>, the SLI resource exposes a single boolean, <code>enableAlert</code>. The fast burn and slow burn thresholds and the action group selection that the portal offers on the Baseline + Alert tab have no representation anywhere in the API surface, and there is no SLI specific alert type under <code>Microsoft.AlertsManagement</code> or <code>Microsoft.Insights</code> either. Read the API cold and the reasonable conclusion is that SLI alerting is portal-only, a boolean with nowhere for the real configuration to live.</p>
<p>I believed that for the better part of a day, and started building a parallel implementation in <code>Microsoft.AlertsManagement/prometheusRuleGroups</code> to work around it. Wrong. <code>enableAlert</code> is a display flag. The real configuration lives in ordinary <code>Microsoft.Insights/metricAlerts</code> resources, using <code>Microsoft.Azure.Monitor.PromQLCriteria</code>, that read the SLI's own published <code>:good</code>/<code>:total</code> metrics, and every bit of it deploys from Bicep. I cover the discovery, the queries, and the working resource definition in a companion post: <a class="" href="https://luke.geek.nz/azure/monitor-sli-alerting-as-code/">Azure Monitor SLI alerting really is deployable as code, just not where you'd look</a>.</p>
<p>The parallel <code>prometheusRuleGroups</code> implementation I built first is worth knowing about as a cautionary tale, not a recommendation. My selectors summed across all three service tiers and left health-check paths in the denominator, both of which quietly made the alert less sensitive than the SLO it was meant to enforce. Deriving alerts from the SLI's own output instead of recomputing them is the actual fix, and it's the pattern I'd ship.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-7-the-burn-rate-windows-everybody-quotes-assume-a-30-day-budget">Gotcha 7: the burn rate windows everybody quotes assume a 30 day budget<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-7-the-burn-rate-windows-everybody-quotes-assume-a-30-day-budget" class="hash-link" aria-label="Direct link to Gotcha 7: the burn rate windows everybody quotes assume a 30 day budget" title="Direct link to Gotcha 7: the burn rate windows everybody quotes assume a 30 day budget" translate="no">​</a></h2>
<p>This one is not an Azure quirk at all, which is exactly why it is worth writing down. It cost me more time than any of the API problems above, and it will be sitting in a lot of SLO implementations right now.</p>
<p>Every reference for multi window burn rate alerting gives you the same table: fast burn at 14.4x over 1h and 5m, slow burn at 6x over 6h and 30m. What almost nobody carries along with it is the precondition. Those windows assume a <strong>30 day</strong> error budget.</p>
<p>My demo uses a 1-day compliance window, because on 30 days a fresh error budget barely moves and there is nothing to show. I copied the canonical windows in anyway.</p>
<p>The failure mode is not what you would expect. It is not that the alert is noisy, or that it fires late. It is that the <strong>long window is the rate limiter</strong>, and it makes partial faults invisible. A rolling 1h average starting from zero has to be physically dragged up to the threshold, and how long that takes depends on how big the fault is. Against a 7.2% threshold:</p>
<table><thead><tr><th>Injected error rate</th><th>Time for the 1h window to cross</th></tr></thead><tbody><tr><td>95%, a total outage</td><td>about 4.5 minutes</td></tr><tr><td>20%</td><td>about 22 minutes</td></tr><tr><td>8%</td><td>about 54 minutes</td></tr></tbody></table>
<p>Read that table again, because the shape of it is the point. The alert fires promptly for a total outage and effectively never fires for a realistic partial degradation.</p>
<p>And here is why it survives review: the fault everyone tests with is a total outage. Kill the pod, watch the page arrive, tick the box. The configuration passes every test you throw at it while being unable to detect the degradations you are most likely to actually experience.</p>
<p>I only noticed because I wanted a better demo. A crashing pod is a weak SLO story, since Kubernetes already tells you about a crashing pod. What I wanted was the fault where every pod is <code>Running</code> with zero restarts and users are still failing, because that is the entire argument for SLIs on one screen. When I injected a 20% error rate, nothing fired. My first instinct was that the fault was too small. It was not, the window was too long.</p>
<p>Shortening the fast burn long window from 1h to 15m fixed it. Measured on the live environment: a 20% error rate now pages at Sev1 in <strong>8 minutes 36 seconds</strong>, which is faster than the total outage managed under the old windows.</p>
<p>If you take one thing from this post, take this: <strong>derive your long window from your compliance window, and test with a partial fault.</strong> Compute <code>long_window x threshold / expected_error_rate</code> and ask whether that many minutes is acceptable for the smallest degradation you care about. Copying 1h/5m onto a 1-day budget is as wrong as copying it onto a 1-hour budget, and it fails silently in the direction of missing real incidents.</p>
<p>A related trap while you are testing: a long window keeps carrying the previous incident's errors well after recovery, so a slow burn rule can still be Fired from your <em>last</em> test run. I nearly recorded a stale Sev2 as a successful detection. Filter alerts by <code>startDateTime</code> against your injection time rather than trusting what is on the screen.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-8-the-signal-model-is-much-more-capable-than-the-examples-suggest">Gotcha 8: the signal model is much more capable than the examples suggest<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-8-the-signal-model-is-much-more-capable-than-the-examples-suggest" class="hash-link" aria-label="Direct link to Gotcha 8: the signal model is much more capable than the examples suggest" title="Direct link to Gotcha 8: the signal model is much more capable than the examples suggest" translate="no">​</a></h2>
<p>Every worked example I found uses a single signal source and a <code>signalFormula</code> of <code>"A"</code>. That makes the model look thin. It is not, and I built three noticeably better SLIs once I read the TypeSpec instead of the examples.</p>
<p><strong><code>signalFormula</code> combines multiple sources.</strong> Give each source a distinct <code>signalSourceId</code> and reference them in the formula. Dividing a histogram's <code>_sum</code> by its <code>_count</code> gives you mean latency computed inside the SLI, with no recording rule and no change to your app:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token property">"signals"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"signalFormula"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"A / B"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"signalSources"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"signalSourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"A"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"metricName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http_request_duration_seconds_sum"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"signalSourceId"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"B"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"metricName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"http_request_duration_seconds_count"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p><strong><code>in</code> and <code>notin</code> take a <code>^^</code> delimited string, not a JSON array.</strong> This is documented only in a doc comment on the <code>ConditionOperator</code> union, and it is useful rather than trivia:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"dimensionName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"service"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"operator"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"in"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"value"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"frontend^^api^^upstream-auth-service"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>That is one SLI spanning a whole call chain instead of three separate ones. It matters for design, not just syntax. An edge only availability SLI stays green when something fails deep in the chain and the edge masks it with a cache or a fallback. A journey scoped one does not.</p>
<p><strong><code>windowUptimeCriteria.target</code> is a float</strong>, so sub-second thresholds work fine, and window-based SLIs are worth having alongside request-based ones rather than instead of them. The request-based one answers "what share of requests were good". The window-based one answers "how much of the time was the service bad", and it is the one that stays meaningful in a quiet period where a handful of slow requests can swing a ratio.</p>
<p>One caveat to close that loop, and it is the mistake I made. Because burn rate alerting lives in a parallel system rather than on the SLI resource, nothing keeps the two in step. I ended up with four SLIs, two availability and two latency, and burn rate rules for availability only. The latency SLIs charted perfectly and paged nobody. The list looks symmetrical, which is precisely why nobody catches it. Enumerate your SLIs and your alert rules side by side.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-9-enabling-the-monitoring-add-on-does-not-enable-monitoring">Gotcha 9: enabling the monitoring add-on does not enable monitoring<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-9-enabling-the-monitoring-add-on-does-not-enable-monitoring" class="hash-link" aria-label="Direct link to Gotcha 9: enabling the monitoring add-on does not enable monitoring" title="Direct link to Gotcha 9: enabling the monitoring add-on does not enable monitoring" translate="no">​</a></h2>
<p>This is the one I am most annoyed about, because I hit the same failure mode twice in the same build and still did not recognise it the second time.</p>
<p>Managed Prometheus first. Setting <code>azureMonitorProfile.metrics.enabled = true</code> in Bicep gets you the metrics agent and no metrics. You also need a data collection endpoint, a data collection rule, and an association to the cluster. I knew this, I built it, it worked.</p>
<p>Then Container Insights. <code>omsAgentEnabled: true</code> with the right workspace id. Addon reports enabled. The <code>ama-logs</code> pods run happily. And every single Container Insights table was empty for the entire life of the cluster:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">union withsource=T *</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where TimeGenerated &gt; ago(24h)</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| summarize n=count() by T</span><br></div></code></pre></div></div>
<p>No rows. Not "a bit thin", not "one table missing". Nothing, anywhere, ever. Same root cause as Prometheus: the enable flag provisions the agent, and a <strong>data collection rule associated to the cluster</strong> is what gives it a job. <code>az aks enable-addons</code> creates that rule for you. Bicep does not.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">az monitor data-collection rule association list </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--resource</span><span class="token plain"> </span><span class="token operator">&lt;</span><span class="token plain">cluster-id</span><span class="token operator">&gt;</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--query</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"[].name"</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-o</span><span class="token plain"> tsv</span><br></div></code></pre></div></div>
<p>Expect one association per collection path. I had exactly one, the Prometheus one, and no error anywhere in the portal, the CLI, the addon status, or the pod logs to suggest anything was missing.</p>
<p>Take the DCR shape and the stream list from the canonical onboarding template in <a href="https://github.com/microsoft/Docker-Provider/blob/ci_prod/scripts/onboarding/aks/onboarding-using-msi-auth/existingClusterOnboarding.json" target="_blank" rel="noopener noreferrer" class=""><code>microsoft/Docker-Provider</code></a> rather than writing it from memory. The stream names are exact strings and there are twelve of them.</p>
<p>The knock-on effect is worth stating: my workbook had never had data, and my write-up claimed the SRE Agent "cites the AKS pod event as root-cause evidence". It cannot cite a pod event from an empty table. I had asserted that from the design rather than from an observation.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-10-an-empty-panel-and-an-impossible-query-look-identical">Gotcha 10: an empty panel and an impossible query look identical<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-10-an-empty-panel-and-an-impossible-query-look-identical" class="hash-link" aria-label="Direct link to Gotcha 10: an empty panel and an impossible query look identical" title="Direct link to Gotcha 10: an empty panel and an impossible query look identical" translate="no">​</a></h2>
<p>Once Container Insights was actually collecting, one workbook panel stayed empty. The query was fine, in the sense that it parsed and ran and returned zero rows:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">InsightsMetrics</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where Namespace == 'container.azm.ms/memory'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where Name == 'memoryWorkingSetBytes'</span><br></div></code></pre></div></div>
<p>There is no <code>container.azm.ms/memory</code> namespace. <code>InsightsMetrics</code> carries node, disk and kube-state metrics. Container CPU and memory live in the <strong><code>Perf</code></strong> table under <code>ObjectName == 'K8SContainer'</code>, and to get from there to a namespace you have to join back to <code>KubePodInventory</code>, because <code>Perf.InstanceName</code> is <code>&lt;clusterResourceId&gt;/&lt;podUID&gt;/&lt;containerName&gt;</code>:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">let podMap = KubePodInventory</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where Namespace == 'payment'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| distinct ContainerName, Name;</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">Perf</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| where ObjectName == 'K8SContainer' and CounterName == 'memoryWorkingSetBytes'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| extend ContainerName = strcat(tostring(split(InstanceName, '/')[-2]), '/', tostring(split(InstanceName, '/')[-1]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| join kind=inner podMap on ContainerName</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">| summarize WorkingSetMiB = avg(CounterValue) / 1024 / 1024 by bin(TimeGenerated, 1m), Name</span><br></div></code></pre></div></div>
<p>The lesson generalises well beyond workbooks: <strong>a query against the wrong table returns zero rows forever, and zero rows is indistinguishable from "no data yet" or "nothing is broken right now".</strong> Before you believe an empty panel, strip the filters and run a positive control:</p>
<div class="language-kql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-kql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">InsightsMetrics | summarize count() by Namespace, Name | order by count_ desc</span><br></div></code></pre></div></div>
<p>That one command would have shown me the real namespaces immediately, and it is the first thing I will run next time.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-11-service-group-membership-looks-tenant-blocked-and-is-actually-an-api-version-trap">Gotcha 11: Service Group membership looks tenant-blocked, and is actually an api-version trap<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-11-service-group-membership-looks-tenant-blocked-and-is-actually-an-api-version-trap" class="hash-link" aria-label="Direct link to Gotcha 11: Service Group membership looks tenant-blocked, and is actually an api-version trap" title="Direct link to Gotcha 11: Service Group membership looks tenant-blocked, and is actually an api-version trap" translate="no">​</a></h2>
<p>This one I got wrong, publicly and for weeks, so it is worth walking through properly.</p>
<p>Service groups are pitched as spanning resources across subscriptions, which is exactly what makes them attractive for a service-level SLO. I could not add a single member. The path, each step producing a different error:</p>
<ol>
<li class=""><code>PUT .../providers/Microsoft.Relationships/serviceGroupMember/&lt;name&gt;</code> returns <code>SubscriptionNotRegistered</code>.</li>
<li class=""><code>az provider register --namespace Microsoft.Relationships</code> succeeds.</li>
<li class="">Retry: <code>HttpRequestPayloadAPISpecValidationFailed</code>. Two things buried in the details array: the name must match <code>^[a-zA-Z0-9]{3,64}$</code>, so hyphens are rejected, and <code>sourceId</code> is required alongside <code>targetId</code>.</li>
<li class="">Retry with a valid payload: <strong><code>RelLifecycleNotEnabledForTenant</code></strong>, <em>"Relationship lifecycle callbacks are not enabled for tenant."</em></li>
</ol>
<p>Undocumented flag, no self-serve way to enable it. I recorded it as a hard limitation and designed around it.</p>
<p>It is not a limitation. <strong>Step 4 only happens on <code>2026-03-01-preview</code>.</strong></p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)"># fails: RelLifecycleNotEnabledForTenant</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT </span><span class="token punctuation" style="color:rgb(248, 248, 242)">..</span><span class="token plain">./serviceGroupMember/slisreShowcase?api-version</span><span class="token operator">=</span><span class="token number">2026</span><span class="token plain">-03-01-preview</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"properties"</span><span class="token plain">:</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"sourceId"</span><span class="token builtin class-name" style="color:rgb(189, 147, 249)">:</span><span class="token string" style="color:rgb(255, 121, 198)">"/subscriptions/&lt;sub&gt;"</span><span class="token plain">,</span><span class="token string" style="color:rgb(255, 121, 198)">"targetId"</span><span class="token builtin class-name" style="color:rgb(189, 147, 249)">:</span><span class="token string" style="color:rgb(255, 121, 198)">"&lt;sg&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># succeeds</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">PUT </span><span class="token punctuation" style="color:rgb(248, 248, 242)">..</span><span class="token plain">./serviceGroupMember/slisreShowcase?api-version</span><span class="token operator">=</span><span class="token number">2023</span><span class="token plain">-09-01-preview</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"properties"</span><span class="token plain">:</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"targetId"</span><span class="token builtin class-name" style="color:rgb(189, 147, 249)">:</span><span class="token string" style="color:rgb(255, 121, 198)">"&lt;sg&gt;"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>The older version infers <code>sourceId</code> from the parent scope and provisions in seconds. The newer one demands <code>sourceId</code> explicitly and <em>then</em> refuses on a tenant flag. So the newest preview is the more restrictive one, which is the opposite of the assumption that sent me down this path. I had reached for the newest api-version precisely because the older one seemed to be failing, and in doing so swapped a solvable error for an unsolvable-looking one.</p>
<p>What actually misled me is subtler than the version number. Errors 1, 3 and 4 are all real and all different, so each retry felt like progress toward a wall. A single error repeated would have made me question the request; a <em>sequence</em> of distinct, plausible errors reads like a narrowing path toward a wall.</p>
<p><strong>And once it works, it is ordinary Bicep</strong>, because the relationship's parent is the <em>member</em>, not the service group:</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">targetScope</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'subscription'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">resource</span><span class="token plain"> serviceGroupMembership </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Relationships/serviceGroupMember@2023-09-01-preview'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'slisreShowcaseBicep'</span><span class="token plain">          </span><span class="token comment" style="color:rgb(98, 114, 164)">// alphanumeric only</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">properties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">targetId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">serviceGroupId</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>That is a subscription-scoped resource, so it deploys from the same template as everything else. Which produces an odd asymmetry worth knowing:</p>
<table><thead><tr><th></th><th>Parent</th><th>Deployment scope needed</th><th>In Bicep?</th></tr></thead><tbody><tr><td>Service group <strong>membership</strong></td><td>the member subscription</td><td>subscription</td><td><strong>yes</strong></td></tr><tr><td>The <strong>SLI</strong> itself</td><td>the service group</td><td>tenant</td><td>no, without RBAC at <code>/</code></td></tr></tbody></table>
<p>Same feature area, opposite answers, purely because of which end of the relationship owns the resource.</p>
<p><strong>Does membership matter?</strong> Less than the effort suggests, and the reason is worth understanding. An SLI does not reference Azure resources at all: its scope is <code>sourceAmwAccountResourceId</code> plus <code>metricNamespace</code>, <code>metricName</code>, and <strong>dimension filters</strong>. My <code>journey-availability</code> SLI spans three tiers purely because of <code>service in "frontend^^api^^upstream-auth-service"</code>. Membership gives you topology and rollup; it changes no SLI's computation.</p>
<p>What it does buy is letting something else <em>tell</em> you what composes the service, rather than you asserting it. That matters for the SRE Agent, which scopes its knowledge graph to a resource group: if a service actually spans resource groups, the agent investigates one and silently misses the rest.</p>
<p><strong>The lesson, and it is the same one as the alerting post:</strong> when an Azure preview API refuses, check whether an <em>older</em> api-version accepts. Newer previews tighten validation and add gates, and the resource provider will happily tell you which versions exist: the error at the wrong version named all of them, and I did not read it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="gotcha-12-get-returns-shapes-that-put-rejects">Gotcha 12: GET returns shapes that PUT rejects<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#gotcha-12-get-returns-shapes-that-put-rejects" class="hash-link" aria-label="Direct link to Gotcha 12: GET returns shapes that PUT rejects" title="Direct link to Gotcha 12: GET returns shapes that PUT rejects" translate="no">​</a></h2>
<p>Round-tripping a resource (GET it, change one field, PUT it back) is the most natural thing to try, and it fails in several places across this stack:</p>
<ul>
<li class=""><strong>SLI</strong>: the <code>identity.userAssignedIdentities</code> values from GET are rejected on PUT with <code>InvalidIdentityValues</code>. They must be empty objects (<code>{}</code>).</li>
<li class=""><strong>SRE Agent response plans</strong>: GET returns <code>isDeleted</code>, <code>documentType</code>, <code>partitionKey</code>, <code>createdAt</code>, <code>updatedAt</code>; POST rejects all five with <code>Unknown incident filter properties</code>.</li>
<li class=""><strong>SRE Agent skills and subagents</strong>: GET returns array fields as <em>stringified</em> lists (<code>"tools": "['RunAzCliReadCommands']"</code>), while PUT requires real JSON arrays. Sending back what you received yields <code>400 ... could not be converted to System.Collections.Generic.List</code>.</li>
</ul>
<p>Strip server-generated fields and rebuild write payloads from the documented shape rather than from a GET response.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="two-sli-correctness-traps-while-you-are-here">Two SLI correctness traps while you are here<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#two-sli-correctness-traps-while-you-are-here" class="hash-link" aria-label="Direct link to Two SLI correctness traps while you are here" title="Direct link to Two SLI correctness traps while you are here" translate="no">​</a></h2>
<p>Neither of these is an Azure quirk, they are just easy to get wrong, and both quietly produce an SLI that measures the wrong thing.</p>
<p><strong>Health probe traffic.</strong> Your scraped series includes <code>/healthz</code> and <code>/readyz</code>. Those never fail, so leaving them in the denominator dilutes the error ratio and your SLO will not move the way you expect. Filter to the user journey path.</p>
<p><strong>Multi-tier double counting.</strong> If every tier emits the same metric name, one user request is counted once per hop. Filter to the edge service, otherwise a three-tier app inflates the denominator threefold.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-actually-found-the-answers">What actually found the answers<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#what-actually-found-the-answers" class="hash-link" aria-label="Direct link to What actually found the answers" title="Direct link to What actually found the answers" translate="no">​</a></h2>
<p>Worth saying, because the method matters more than the specific values.</p>
<p>Searching the docs and Learn got me nowhere on the namespace. What found it was searching <strong>GitHub code for the literal error string</strong>, <code>DestinationAmwAccountAccessValidator</code>. That returned exactly one result, a <code>workarounds.md</code> in Toru Makabe's <a href="https://github.com/torumakabe/aks-chaos-lab" target="_blank" rel="noopener noreferrer" class="">aks-chaos-lab</a>, which documents both the DCR RBAC requirement and the metrics-must-exist-first constraint, and whose SLI Bicep module has <code>metricNamespace</code> defaulting to <code>customdefault</code>. Full credit there, that repo saved me a support case.</p>
<p>If you are stuck on an Azure error that reads like an internal validator name, put the exact string into GitHub code search before you do anything else. Somebody has usually hit it and written it down.</p>
<p>The other thing that paid off was reading the <strong>TypeSpec</strong> rather than the examples. <code>sliProperties.tsp</code> in <a href="https://github.com/Azure/azure-rest-api-specs" target="_blank" rel="noopener noreferrer" class=""><code>Azure/azure-rest-api-specs</code></a> is about 260 lines and took five minutes, and it is where the <code>^^</code> delimiter, the float target, and the multi source formula all are. The examples had led me to believe none of those existed.</p>
<p>And one anti method, since it wasted an hour. When I could not find an API to list the SRE Agent's incidents, I started guessing endpoint paths. Three of them returned HTTP 200, which felt like progress, right up until I looked at the body and found the portal's single page app HTML rather than JSON. A 200 from a SPA host means "this host serves a web app", not "this endpoint exists". If you are guessing paths, you have already left the part of the problem that is solvable by guessing.</p>
<p>What actually worked was asking what the service <strong>emits</strong> rather than what it exposes. The agent resource has a <code>logConfiguration.applicationInsightsConfiguration.appId</code>, and the agent writes an <code>IncidentActivitySnapshot</code> custom event to it on every incident transition, carrying the response plan id, the autonomy level, the handled timestamp, and the agent's written investigation summary. That answered in one KQL query what an hour of path guessing could not. When an Azure service will not give you an API, check its telemetry before you reach for its UI.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="wrap-up">Wrap up<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#wrap-up" class="hash-link" aria-label="Direct link to Wrap up" title="Direct link to Wrap up" translate="no">​</a></h2>
<p>None of this makes Azure Monitor SLIs a bad feature. The error budget and burn rate experience is useful, and having it native beats maintaining your own burn rate rules. But the preview API surface is sharp in places, and the failure modes are almost all generic errors that point away from the real cause.</p>
<p>The short version for anyone starting out:</p>
<ul>
<li class="">Use <code>customdefault</code> as the metric namespace for Managed Prometheus metrics.</li>
<li class="">Put the identity roles on the default DCR in <code>MA_&lt;amw&gt;_&lt;region&gt;_managed</code>, not on the workspace.</li>
<li class="">Debug with the create call, not <code>sliSignalPreview</code>.</li>
<li class="">Check the portal notification pane for the real error.</li>
<li class="">Configure the service group default AMW first, and wait for your metrics to land.</li>
<li class="">Don't recompute burn rate alerting yourself. Derive it from the SLI's own <code>:good</code>/<code>:total</code> metrics via <code>Microsoft.Insights/metricAlerts</code> and <code>PromQLCriteria</code>, it's IaC-able, just not where the SLI resource itself suggests.</li>
</ul>
<p>Hopefully this saves you the afternoon it cost me.</p>
<p>The full Bicep, scripts, and agent configuration for the showcase this was all found while building are public: <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/monitor-sli-managed-prometheus-gotchas/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Create service level indicators in Azure Monitor</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/templates/microsoft.monitor/2025-03-01-preview/slis?pivots=deployment-language-bicep&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Microsoft.Monitor/slis template reference</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/azure-monitor/aiops/issues-how-to?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Use Azure Monitor issues (default AMW association)</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/governance/service-groups/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Service groups overview</a></li>
<li class=""><a href="https://github.com/torumakabe/aks-chaos-lab" target="_blank" rel="noopener noreferrer" class="">torumakabe/aks-chaos-lab</a></li>
<li class=""><a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Designing User-Centred Reliability with Azure Monitor SLIs and the Azure SRE Agent]]></title>
            <link>https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/</link>
            <guid>https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/</guid>
            <pubDate>Thu, 13 Aug 2026 00:47:11 GMT</pubDate>
            <description><![CDATA[How to design user-centred reliability signals with Azure Monitor SLIs, Service Groups, error budgets, and Azure SRE Agent remediation for an AKS workload.]]></description>
            <content:encoded><![CDATA[<p>Most Kubernetes monitoring tells you that a pod restarted. It rarely tells you whether a customer noticed, which customers were affected, or whether the business promise is now at risk.</p>
<p>That gap is a product and service-design problem as much as a monitoring problem. The people responsible for a payment journey need to know whether checkout works, how quickly it works, and which customer segment is paying the cost of an incident. The on-call engineer needs enough evidence to choose the right first action. The business needs a trustworthy way to decide when an error budget has been spent.</p>
<p>I wanted to see whether an <a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Monitor SLI</a> could give the <a href="https://learn.microsoft.com/azure/sre-agent/overview?tabs=task&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure SRE Agent</a> enough context to investigate an <a href="https://learn.microsoft.com/azure/aks/what-is-aks?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Kubernetes Service</a> failure, rather than just react to a pod alert. I built a small payment service, chose signals around the customer journey, and deliberately broke it.</p>
<p>The result is an infrastructure-as-code deployment that runs with <code>azd up</code>. It measures availability, latency, the full request journey, the public path, and customer tiers. Azure Monitor alerts on those service-level signals, and the Azure SRE Agent investigates the incident and, when allowed, fixes it.</p>
<p>The important result is not the number of Azure resources. It is the chain from customer need to operational decision:</p>
<table><thead><tr><th>Question</th><th>Evidence</th><th>Decision it supports</th></tr></thead><tbody><tr><td>Can customers complete checkout?</td><td>Journey availability</td><td>Treat the incident as a service problem, not a pod problem</td></tr><tr><td>Who is affected?</td><td>Customer-tier dimensions</td><td>Prioritise impact and communicate with the right customers</td></tr><tr><td>Is the public entry point working?</td><td>External availability</td><td>Avoid restarting healthy workloads when DNS or TLS is broken</td></tr><tr><td>Is the budget being spent quickly?</td><td>Burn rate</td><td>Decide whether to wake someone or continue observing</td></tr><tr><td>Is a remediation safe?</td><td>Logs, metrics, RBAC, and audit evidence</td><td>Automate only actions with a bounded risk</td></tr></tbody></table>
<p>I got several things wrong. The corrections stay in the post because they expose the difference between a design that looks plausible and one that has been tested with the people, decisions, and failure modes it is meant to serve.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="who-this-is-for">Who this is for<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#who-this-is-for" class="hash-link" aria-label="Direct link to Who this is for" title="Direct link to Who this is for" translate="no">​</a></h2>
<p>This walkthrough is aimed at teams that share responsibility for a customer-facing service:</p>
<ul>
<li class="">Platform engineers who own Azure Monitor workspaces, identities, collection, and RBAC</li>
<li class="">SRE and operations teams who define SLOs, error-budget policy, and incident response</li>
<li class="">Product and engineering leaders who need reliability data expressed in terms of customer impact</li>
<li class="">Teams evaluating how much operational work an AI agent should investigate, propose, or perform</li>
</ul>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_BuS1"><blockquote>
<p>Before getting into the build, three terms need separating. An <strong>SLI (Service Level Indicator)</strong> is the measured number, such as "99.2% of checkout requests succeeded in the last 24 hours." An <strong>SLO (Service Level Objective)</strong> is the internal target for that number, such as "availability stays above 99% over a rolling 28 days." An <strong>SLA (Service Level Agreement)</strong> is the contractual promise to a customer, and should be looser than the SLO.</p>
</blockquote><p>Most teams have an SLA somewhere in a contract, an SLO nobody agreed on, and an SLI that's really just "whatever our dashboard happens to show."</p><p>Azure SRE Agent is Microsoft's AI agent product for exactly this problem: it watches Azure Monitor alerts, investigates using your actual logs and metrics, and, if configured to do so, takes a remediation action itself and looks for root cause if it has access to the codebase. No human has to be paged, triage the graphs, and type <code>kubectl scale</code> at 3am. The agent does the first pass, and depending on how much you trust it for a given incident class, either proposes what it would do or does it.</p><p>The outcome I wanted was an SLO breach that names which customer segment is affected, a differentiated service commitment expressed in Azure Monitor, and an AI agent that checks the other firing SLIs before it touches a workload.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="start-with-the-service-promise">Start with the service promise<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#start-with-the-service-promise" class="hash-link" aria-label="Direct link to Start with the service promise" title="Direct link to Start with the service promise" translate="no">​</a></h2>
<!-- -->
<p>The arrow from <code>slis</code> to <code>alerts</code> is the design decision I cared about. The alerts read the SLI's own published metrics, not a second implementation built from raw application counters. That keeps the alert and the SLO measuring the same customer promise.</p>
<p>This is the first design-thinking checkpoint: define the outcome before choosing the tool. “A pod is ready” is an implementation fact. “A customer can complete checkout within 500 milliseconds” is an outcome. Both are useful, but they answer different questions and should not be treated as interchangeable.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_BuS1"><p>The repository, including the Bicep, fault-injection scripts, and agent configuration, is public: <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="give-the-service-a-durable-identity">Give the service a durable identity<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#give-the-service-a-durable-identity" class="hash-link" aria-label="Direct link to Give the service a durable identity" title="Direct link to Give the service a durable identity" translate="no">​</a></h2>
<p>An SLI in Azure Monitor is an extension resource on a tenant-scoped Service Group:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;/providers/Microsoft.Monitor/slis/&lt;name&gt;</span><br></div></code></pre></div></div>
<p>That scope is the point. A service is rarely one resource group: frontend here, API there, and a database in a shared subscription. A tenant-scoped Service Group gives the service an identity that outlives any particular resource layout, so the SLO follows the customer experience rather than a cluster.</p>
<p>For a platform team, this reduces the cost of change. Teams can move workloads, split deployments, or replace a dependency without making the reliability conversation start again. For a product team, it creates a stable place to discuss whether the service is meeting its promise.</p>
<p>There is an important boundary here: a <a href="https://learn.microsoft.com/azure/governance/service-groups/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Service Group</a> is an operational view, not a security boundary. It is currently in public preview, and membership does not grant access to the resources inside it. A resource can belong to multiple Service Groups, which is useful when platform, product, and customer-support teams need different views of the same service. Keep resource RBAC and Service Group RBAC as separate design decisions.</p>
<p>The Service Group does not define what the SLI measures. There is no membership involved. An SLI's scope is <code>sourceAmwAccountResourceId</code> <a href="https://learn.microsoft.com/azure/azure-monitor/metrics/azure-monitor-workspace-overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">(Azure Monitor Workspace)</a> plus a metric name and dimension filters:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">"filters": [</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "namespace",   "operator": "eq",            "value": "payment" },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "service",     "operator": "eq",            "value": "frontend" },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "path",        "operator": "eq",            "value": "/checkout" },</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  { "dimensionName": "status_code", "operator": "notstartswith", "value": "5" }</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">]</span><br></div></code></pre></div></div>
<p>The Service Group is the SLO's home, not its definition. Membership gives you topology and rollup, but changes nothing about what any SLI computes.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="plan-the-sli-identity-path">Plan the SLI identity path<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#plan-the-sli-identity-path" class="hash-link" aria-label="Direct link to Plan the SLI identity path" title="Direct link to Plan the SLI identity path" translate="no">​</a></h3>
<p>The SLI also has an identity and data-storage path that is easy to miss when the portal hides the plumbing. Azure Monitor reads the source Azure Monitor Workspace and writes the evaluated SLI results to a destination workspace. A user-assigned managed identity needs Monitoring Reader on the source workspace, Monitoring Reader plus Monitoring Metrics Publisher on the destination workspace, and Monitoring Reader on the destination workspace's default data collection rule. The source and destination can be the same workspace, but separating raw telemetry from evaluated reliability data can make ownership and access easier to explain.</p>
<p>This is a useful handoff between platform and product teams. The product team defines what “good” means. The platform team owns the identity, workspace boundaries, collection rules, and retention that make that definition executable.</p>
<p>Membership is ordinary Bicep, with one sharp edge. <code>2026-03-01-preview</code> returned <code>RelLifecycleNotEnabledForTenant</code>; the older <code>2023-09-01-preview</code> worked:</p>
<div class="language-bicep codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bicep codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">targetScope</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'subscription'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">resource</span><span class="token plain"> serviceGroupMembership </span><span class="token string" style="color:rgb(255, 121, 198)">'Microsoft.Relationships/serviceGroupMember@2023-09-01-preview'</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'slisreShowcaseBicep'</span><span class="token plain"> </span><span class="token comment" style="color:rgb(98, 114, 164)">// alphanumeric only, 3-64 chars</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">properties</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">targetId</span><span class="token operator">:</span><span class="token plain"> </span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'/providers/Microsoft.Management/serviceGroups/</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token interpolated-string interpolation expression">serviceGroupId</span><span class="token interpolated-string interpolation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token interpolated-string string" style="color:rgb(255, 121, 198)">'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>The relationship's parent is the member, not the Service Group, so this is subscription-scoped. The SLIs are the opposite case: their parent is the Service Group, which forces a tenant-scoped deployment and an Azure RBAC grant at <code>/</code> that Global Administrator does not confer.</p>
<p>You can express "one SLO across frontend, API and auth" today with one multi-value filter:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">{</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "dimensionName": "service",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "operator": "in",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "value": "frontend^^api^^upstream-auth-service",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">}</span><br></div></code></pre></div></div>
<p><code>in</code> and <code>notin</code> take a <code>^^</code>-delimited string, not a JSON array. That's documented in one TypeSpec comment and nowhere else.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="turn-customer-needs-into-measurable-questions">Turn customer needs into measurable questions<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#turn-customer-needs-into-measurable-questions" class="hash-link" aria-label="Direct link to Turn customer needs into measurable questions" title="Direct link to Turn customer needs into measurable questions" translate="no">​</a></h2>
<!-- -->
<p>Each SLI exists because the basic availability number leaves a different customer or business question unanswered. A sixth, <code>latency-windowed</code>, measures the same 500ms bar as mean latency per five-minute window rather than per request, and is covered separately below.</p>
<p>Azure Monitor offers two evaluation methods. Request-based evaluation asks whether the ratio of good requests to total requests meets the target. Window-based evaluation asks whether time intervals meet a quality threshold. Request-based evaluation is usually the better fit for checkout because one failed transaction should count as a failed transaction, even during a quiet period. Window-based evaluation can be useful when you want to smooth short bursts or measure the proportion of time a system remains within a limit. Choose the method with the customer experience and decision in mind, not the metric that happens to be easiest to query.</p>
<p><img decoding="async" loading="lazy" alt="The Manage SLIs grid for the six SLIs, live attainment percentages and error budget bars, then clicking into tier-availability shows the full query definition plus live Metric, Error Budget Remaining, and Burn Rate charts split by customer_tier" src="https://luke.geek.nz/assets/images/sli-portal-walkthrough-5abb53cb12a45ba8112215dda8afa108.gif" width="1494" height="782" class="img_ev3q"></p>
<table><thead><tr><th>SLI</th><th>User or business question</th></tr></thead><tbody><tr><td><code>availability</code></td><td>Is the checkout edge returning successful responses?</td></tr><tr><td><code>journey-availability</code></td><td>Does the complete request journey work across the frontend, API, and auth service?</td></tr><tr><td><code>latency</code></td><td>Can customers complete the action quickly enough to trust the service?</td></tr><tr><td><code>external-availability</code></td><td>Can a user outside the cluster reach the public service?</td></tr><tr><td><code>tier-availability</code></td><td>Is the impact concentrated in a customer segment?</td></tr></tbody></table>
<p><code>availability</code> measures the edge at <code>/checkout</code>: good requests over total. <code>journey-availability</code> spans all three tiers with the <code>in</code> filter. An edge-only SLI stays green when the API fails and the frontend serves a cached fallback, while the user's journey is broken.</p>
<p><code>latency</code> exists because a service that answers every request in nine seconds is up, and useless. Good is the <code>le="0.5"</code> histogram bucket; total is the request count.</p>
<p><code>external-availability</code> matters because every SLI above is computed from traffic generated inside the cluster. You cannot measure an outage from inside the thing that is unreachable. If DNS breaks or the load balancer misroutes, no request arrives, so there is no error metric and no denominator either. Availability reads 100% during a total outage. That is structural; it needs something outside the workload requesting the service over the real network path.</p>
<p><code>tier-availability</code> is the same measurement as <code>availability</code>, partitioned by who the customer is rather than by which component served them.</p>
<p>For any SLO, ask which signal moves if DNS breaks. If none does, it measures instrumentation rather than availability. Then ask who needs to act on the signal and what decision it should change. That turns a dashboard metric into a useful service conversation.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="choose-signals-that-represent-people-not-components">Choose signals that represent people, not components<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#choose-signals-that-represent-people-not-components" class="hash-link" aria-label="Direct link to Choose signals that represent people, not components" title="Direct link to Choose signals that represent people, not components" translate="no">​</a></h2>
<p>The demo app is instrumented with the Prometheus client because I wrote it. Most workloads you need SLOs for are not yours to change, and that's fine: the SLI reads Prometheus series from an Azure Monitor Workspace and does not care what produced them.</p>
<table><thead><tr><th>Source</th><th>App change</th><th>Gives you</th></tr></thead><tbody><tr><td>Ingress controller metrics</td><td>None</td><td><code>nginx_ingress_controller_requests{service,status}</code></td></tr><tr><td>Service mesh sidecar</td><td>None</td><td>Per-hop RED metrics</td></tr><tr><td><code>blackbox_exporter</code></td><td>None</td><td><code>probe_success</code> and certificate-expiry alerting</td></tr><tr><td>OTel auto-instrumentation</td><td>None (agent)</td><td>In-process HTTP metrics</td></tr><tr><td>App instrumentation</td><td>Yes</td><td>Whatever you choose</td></tr></tbody></table>
<p>Prefer an infrastructure-emitted signal even when you can instrument the app. It survives redeploys, language changes and vendor upgrades, and is uniform across every service behind it.</p>
<p>One trap cost me hours: for Managed Prometheus sources, <code>metricNamespace</code> must be <code>customdefault</code>, not <code>prometheus</code>. With the wrong value the metric resolves fine and SLI creation fails with errors about dimensions, which is entirely the wrong place to look.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="measuring-users-not-components">Measuring users, not components<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#measuring-users-not-components" class="hash-link" aria-label="Direct link to Measuring users, not components" title="Direct link to Measuring users, not components" translate="no">​</a></h3>
<p>An SLI's scope is a dimension filter, not a resource. <code>journey-availability</code> spans three Kubernetes Deployments purely because of:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"dimensionName"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"service"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"operator"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"in"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"value"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"frontend^^api^^upstream-auth-service"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>Add a fourth tier tomorrow and it joins the SLO by emitting the label. Nothing gets registered. The SLO describes the journey; the deployment topology is free to change underneath it.</p>
<table><thead><tr><th>Filter on</th><th>The SLO says</th></tr></thead><tbody><tr><td><code>pod</code>, <code>container</code>, <code>node</code></td><td>"a component is unhealthy"</td></tr><tr><td><code>service</code>, <code>namespace</code></td><td>"a system is degraded"</td></tr><tr><td><code>path</code>, <code>journey</code></td><td>"an action users take is failing"</td></tr><tr><td><code>customer_tier</code>, <code>tenant</code>, <code>region</code></td><td>"these users are affected"</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-partition-trick">The partition trick<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#the-partition-trick" class="hash-link" aria-label="Direct link to The partition trick" title="Direct link to The partition trick" translate="no">​</a></h3>
<p>The dimensions do not have to be technical. This app emits <code>customer_tier</code> from a request header and propagates it across every hop:</p>
<div class="language-python codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-python codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">KNOWN_TIERS </span><span class="token operator">=</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">frozenset</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string" style="color:rgb(255, 121, 198)">"premium"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"standard"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"free"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">DEFAULT_TIER </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"standard"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">tier </span><span class="token operator">=</span><span class="token plain"> request</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">headers</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">get</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"x-customer-tier"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> DEFAULT_TIER</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">lower</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> tier </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">not</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">in</span><span class="token plain"> KNOWN_TIERS</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">      </span><span class="token comment" style="color:rgb(98, 114, 164)"># never let a caller mint unbounded series</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    tier </span><span class="token operator">=</span><span class="token plain"> DEFAULT_TIER</span><br></div></code></pre></div></div>
<p>The SLI partitions on it via <code>spatialAggregation.dimensions</code>:</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token property">"spatialAggregation"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"type"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"Sum"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token property">"dimensions"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">"customer_tier"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>The alert uses the portal's own idiom:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">sum without ("INCLUDE-ALL-DIMENSIONS-DONT-REMOVE") ({__name__="...:good"})</span><br></div></code></pre></div></div>
<p>Summing without a label that does not exist aggregates while preserving every real dimension, so one alert rule fires once per partition. One SLI, one rule, and the incident names the affected segment.</p>
<p>Break premium only:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">./scripts/inject-fault.sh </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--mode</span><span class="token operator">=</span><span class="token plain">errors </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--rate</span><span class="token operator">=</span><span class="token number">100</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">--tier</span><span class="token operator">=</span><span class="token plain">premium</span><br></div></code></pre></div></div>
<table><thead><tr><th>At the same instant</th><th>Reports</th></tr></thead><tbody><tr><td><code>availability</code></td><td>85.02%. Service degraded. Cause unknown.</td></tr><tr><td><code>tier-availability</code></td><td>premium 0.00%, standard 100.00%, free 100.00%</td></tr></tbody></table>
<p>The second has already eliminated most of the search space: not a capacity problem, not a dependency outage, and not a deployment affecting everyone. Something is routing premium differently. That is a fault class per-service telemetry cannot see at all: every pod is healthy, every tier is up, and only the signal partitioned by who the user is moves.</p>
<p>This is where the business lens changes the response. A blended 85.02% can trigger concern, but it does not tell a product manager whether premium customers, free customers, or everyone is affected. The partitioned result supports prioritisation, customer communication, and a more honest conversation about differentiated service commitments.</p>
<p>Imagine a premium customer trying to complete checkout during this fault. The pods are healthy and the overall availability number is still above zero, but that customer cannot complete the transaction. The tier-specific SLI turns that experience into an operational fact: investigate the premium path first, communicate the impact accurately, and avoid spending the incident response effort on healthy free and standard traffic.</p>
<p>There are four limits:</p>
<ol>
<li class="">Azure cannot infer business dimensions. <code>customer_tier</code> exists because the application chose to emit it.</li>
<li class="">Cardinality is the tax. Keep business dimensions closed and small; tenant IDs are usually a bill, not a feature.</li>
<li class="">Targets are per SLI, not per partition. Differentiated targets need one SLI per tier.</li>
<li class="">In-cluster signals stay blind to the front door. Partitioning does not fix what <code>external-availability</code> exists to fix.</li>
</ol>
<p>The Service Group gives the service a tenant-scoped identity that survives resource churn. Through <code>customProperties.sliId</code>, the agent gets a service-level fact about users rather than a symptom about pods.</p>
<p>Service Groups nest too. A child group joins a parent via <code>properties.parent.resourceId</code> at create time. The differentiated-target SLIs now live in a <code>Customer Segments</code> child group under the parent, without moving the existing six SLIs or their history.</p>
<p><img decoding="async" loading="lazy" alt="A filtered Service Group tree reveals Customer Segments nested under SLI + SRE Agent showcase, then the child group&amp;#39;s overview blade showing the parent" src="https://luke.geek.nz/assets/images/nested-service-group-8ae4d49b346d746c81fe8361c825d645.gif" width="1494" height="782" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="make-the-alert-answer-a-business-question">Make the alert answer a business question<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#make-the-alert-answer-a-business-question" class="hash-link" aria-label="Direct link to Make the alert answer a business question" title="Direct link to Make the alert answer a business question" translate="no">​</a></h2>
<p>Azure Monitor has a <a href="https://learn.microsoft.com/azure/azure-monitor/fundamentals/service-level-indicators-create?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">native SLI alerting path</a>. In the portal, <code>enableAlert</code> can configure a baseline alert, fast-burn alert, and slow-burn alert, with an action group defining who is notified and what downstream action runs. That is the shortest path when you want the platform's standard SLI experience.</p>
<p>I used a second path in this project: explicit <code>Microsoft.Insights/metricAlerts</code> resources using <code>Microsoft.Azure.Monitor.PromQLCriteria</code>, scoped to the Azure Monitor Workspace. That gave me infrastructure-as-code control over the PromQL, dimensions, custom properties, and alert names used by the SRE Agent. It is not a replacement for native SLI alerting. Decide which path owns the policy, and avoid enabling both for the same condition unless duplicate notifications are intentional.</p>
<p>They read the SLI's own published metrics, <code>&lt;sli&gt;:Good</code>, <code>&lt;sli&gt;:Total</code>, and <code>&lt;sli&gt;:Value</code>:</p>
<div class="language-promql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-promql codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">(</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ( sum(increase({__name__="ns::&lt;sg&gt;/m::availability:total"}[15m]))</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  - sum(increase({__name__="ns::&lt;sg&gt;/m::availability:good"}[15m])) )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  /</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  ( sum(increase({__name__="ns::&lt;sg&gt;/m::availability:total"}[15m])) * (1 - 0.99) )</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">) &gt; 14</span><br></div></code></pre></div></div>
<p>I originally recomputed the same signal in parallel Prometheus rules. Those selectors summed across all three tiers and left <code>/healthz</code> in the denominator, making the alert materially less sensitive than the SLO. Both objects looked correct in isolation, and total-outage testing did not catch it. Detection latency was 9m47s versus 5m20s on the same outage.</p>
<p>Alerts derived from the SLI's own output cannot drift from it. That safety property is the technical argument. The business argument is that the alert represents the same commitment people agreed to measure, rather than an engineer's approximation of it.</p>
<p><img decoding="async" loading="lazy" alt="Diagram showing how the Azure Monitor portal&amp;#39;s enableAlert setting relates to the separate metric alert and PromQL configuration" src="https://luke.geek.nz/assets/images/alerting-as-code-reveal-a3c4b80843a3823c0ea3409065160c54.svg" width="1173" height="456" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="use-error-budgets-to-guide-attention">Use error budgets to guide attention<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#use-error-budgets-to-guide-attention" class="hash-link" aria-label="Direct link to Use error budgets to guide attention" title="Direct link to Use error budgets to guide attention" translate="no">​</a></h2>
<p>Burn rate is how fast you're spending error budget. At a 99% target, a 2% error rate burns at 2x; a 20% error rate burns at 20x. That makes an error budget a decision tool: it connects reliability work to the limited amount of failure the service can afford before customers and the business feel the impact.</p>
<p>The canonical 14x over one hour and 6x over six hours assumes a 30-day budget. On a one-day window with a 0.5% budget, the long window becomes the rate limiter:</p>
<table><thead><tr><th>Injected error rate</th><th>Time for the 1h window to trip</th></tr></thead><tbody><tr><td>95% (total outage)</td><td>~4.5 min</td></tr><tr><td>20%</td><td>~22 min</td></tr><tr><td>8%</td><td>~54 min</td></tr></tbody></table>
<p>Derive the long window from the compliance window. Shortening it to 15 minutes took a 20% error rate from roughly 22 minutes to a measured 8m36s end to end.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-correction-sum_over_time-was-wrong-here">A correction: <code>sum_over_time()</code> was wrong here<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#a-correction-sum_over_time-was-wrong-here" class="hash-link" aria-label="Direct link to a-correction-sum_over_time-was-wrong-here" title="Direct link to a-correction-sum_over_time-was-wrong-here" translate="no">​</a></h3>
<p>The SLI's <code>:good</code> and <code>:total</code> are cumulative across the compliance window, not counters. <code>sum_over_time(...[15m])</code> therefore sums fifteen cumulative snapshots. It is accumulated damage, not the error rate during those fifteen minutes.</p>
<p>The practical consequence was a complete backend outage that ran for twelve minutes without crossing the 14x threshold. Treat the baseline attainment alert as the fast detector; keep burn-rate alerts for the budget-spend signal. If true short-window sensitivity is required, compute it from raw application counters with <code>rate()</code>, accepting the drift risk this design otherwise avoids.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-second-correction-rate-was-not-the-problem">A second correction: <code>rate()</code> was not the problem<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#a-second-correction-rate-was-not-the-problem" class="hash-link" aria-label="Direct link to a-second-correction-rate-was-not-the-problem" title="Direct link to a-second-correction-rate-was-not-the-problem" translate="no">​</a></h3>
<p>That conclusion was half wrong. <code>sum_over_time()</code>/<code>rate()</code> returning empty was true of exactly one test: a brand-new SLI queried within its first evaluation cycles. <code>rate()</code> and <code>increase()</code> need at least two samples spanning the lookback window. After a day of samples, <code>rate()</code>, <code>increase()</code> and <code>delta()</code> all returned normal values.</p>
<p>On identical 15-minute fault injections, <code>sum_over_time()</code> took 49 minutes to trip; <code>increase()</code> took 10m06s, most of which was the mandatory five-minute sustained-condition window. Use <code>increase()</code>, not <code>sum_over_time()</code>, and interpret an empty PromQL result as "not enough history yet" until proven otherwise.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="design-the-handoff-from-signal-to-action">Design the handoff from signal to action<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#design-the-handoff-from-signal-to-action" class="hash-link" aria-label="Direct link to Design the handoff from signal to action" title="Direct link to Design the handoff from signal to action" translate="no">​</a></h2>
<!-- -->
<p>The agent subscribes to Azure Monitor directly through <code>incidentManagementConfiguration.type: AzMonitor</code>, scoped by <code>knowledgeGraphConfiguration.managedResources</code> to the resource group. It is not wired through an action group. That is a different integration path from native SLI alert notifications: the SRE Agent receives the Azure Monitor incident, while an action group can notify people or invoke other automation.</p>
<p><img decoding="async" loading="lazy" alt="Azure Monitor SLI to Azure SRE Agent, end to end: AKS emits metrics, Managed Prometheus scrapes them into the Azure Monitor Workspace, a tenant-scoped SLI computes error budget and burn rate, an SLI-native alert raises an incident carrying customProperties, and the SRE Agent investigates with Log Analytics before proposing remediation" src="https://luke.geek.nz/assets/images/sli-to-sre-agent-e2e-a75fd83e54296ce60f3d69198e7b2e43.svg" width="1413" height="567" class="img_ev3q"></p>
<p>Each alert carries structural context:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">{</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "sliId": "/providers/.../serviceGroups/&lt;sg&gt;/providers/Microsoft.Monitor/slis/availability",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "serviceGroupId": "/providers/Microsoft.Management/serviceGroups/&lt;sg&gt;",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "alertKind": "fast-burn-rate",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "burnRate": "14",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "lookback": "15m",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">}</span><br></div></code></pre></div></div>
<p>The agent can resolve <code>sliId</code> and learn the target, compliance window, and filters. The incident opens as “the <code>/checkout</code> journey at the frontend edge, committed at 99% over a one-day rolling window, is at 93.4%,” not “a burn-rate rule exceeded 6”. The first version gives an operator or an agent a user-centred problem to investigate.</p>
<p>The handoff also follows a useful service-design pattern: preserve context as the problem moves between people and systems. The alert carries the service, journey, target, customer segment, and time window. The agent can then investigate the likely experience before choosing an implementation-level action.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="correlation-is-the-fastest-diagnosis">Correlation is the fastest diagnosis<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#correlation-is-the-fastest-diagnosis" class="hash-link" aria-label="Direct link to Correlation is the fastest diagnosis" title="Direct link to Correlation is the fastest diagnosis" translate="no">​</a></h3>
<table><thead><tr><th><code>availability</code></th><th><code>external-availability</code></th><th>Cause</th><th>First action</th></tr></thead><tbody><tr><td>Firing</td><td>Firing</td><td>Real workload failure</td><td>Investigate pods and events</td></tr><tr><td>Healthy</td><td>Firing</td><td>DNS, load balancer, TLS</td><td>Do not restart pods</td></tr><tr><td>Firing</td><td>Healthy</td><td>Path the probe does not exercise</td><td>Compare with <code>journey-availability</code></td></tr><tr><td>Healthy</td><td>Healthy</td><td>Suspect the telemetry</td><td>Check meta-monitoring</td></tr></tbody></table>
<p>Given a Deployment scaled to zero, the agent ran 31+ tool calls, queried Log Analytics, correctly root-caused the missing pods, and scaled the Deployment back up. Injection to recovery was about 20 minutes, roughly five of them detection.</p>
<p>The <a href="https://learn.microsoft.com/azure/sre-agent/create-agent" target="_blank" rel="noopener noreferrer" class="">current SRE Agent guidance</a> recommends starting with Reader access, where writes require approval, and moving to Privileged access only after the team trusts the workflow. That is a good pilot model: begin with investigation and proposed remediation, measure false positives and recovery quality, then grant the smallest write permission for a narrow incident class. The agent also creates or uses Application Insights and persists investigation context, so data retention, cost, region, and who can review that operational memory belong in the service design as well. The <a href="https://learn.microsoft.com/azure/sre-agent/security-overview" target="_blank" rel="noopener noreferrer" class="">SRE Agent security overview</a> describes the related identity, isolation, and telemetry considerations.</p>
<p>On a later run it refused to declare success based only on rollout status: "the rollout completed, but the probe and workspace telemetry do not yet provide a current success sample." <code>kubectl</code> confirmed the outcome it was checking for: 2/2 ready.</p>
<p><img decoding="async" loading="lazy" alt="The Azure SRE Agent&amp;#39;s incident view across the scaled-to-zero fault, the rollout it triggered completing, and independent recovery validation" src="https://luke.geek.nz/assets/images/sre-agent-incident-investigation-53626e2ecc6a75f98e027c4cfc171b9d.gif" width="1442" height="795" class="img_ev3q"></p>
<p><img decoding="async" loading="lazy" alt="The Azure SRE Agent investigating a premium-tier fast-burn incident, identifying the breach from alert context, and requesting consent when it cannot read the SLI definition" src="https://luke.geek.nz/assets/images/live-premium-fault-investigation-c5c520edcbfcdbcba73a3a1f2e6d5d9a.gif" width="1494" height="782" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="treat-trust-as-part-of-the-service-design">Treat trust as part of the service design<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#treat-trust-as-part-of-the-service-design" class="hash-link" aria-label="Direct link to Treat trust as part of the service design" title="Direct link to Treat trust as part of the service design" translate="no">​</a></h2>
<p>The agent needs Kubernetes access to act, but permission to act is not the same as evidence that it should act. Scope the permissions tightly:</p>
<table><thead><tr><th>Role</th><th>Scope</th></tr></thead><tbody><tr><td>Azure Kubernetes Service Cluster User</td><td>Cluster; fetches a kubeconfig, grants nothing on its own</td></tr><tr><td>Azure Kubernetes Service RBAC Writer</td><td>One namespace</td></tr></tbody></table>
<p>Writer rather than Admin excludes role bindings, so the agent cannot grant itself anything further.</p>
<p><code>PreToolUse</code> hooks gate individual actions, but only if their matchers bind. Mine shipped with <code>^(restart_|scale_).*</code>, copied from another framework. The real tool names are PascalCase: <code>RunKubectlWriteCommand</code>, <code>Terminal</code>, and <code>RunInTerminal</code>. The hooks matched zero tools while reporting <code>HooksRun: 2, FinalDecision: pass</code>.</p>
<p>When the agent remediated, it used <code>RunInTerminal</code>, not the obvious kubectl-specific tool. A matcher listing only write tools would have missed it.</p>
<p>A response plan with <code>agentMode: review</code> also did not prevent execution in my test. That contradicted the current product guidance, which describes Review mode as requiring approval for write operations, so I treated it as a behaviour to retest rather than a safety guarantee.</p>
<p>From a design-thinking perspective, the question is not “can the AI fix the incident?” It is “what level of autonomy is appropriate for this incident, this customer impact, and this reversibility?” A scale-up in one namespace may be a bounded experiment. A database migration or a change affecting every tenant is a different service risk and needs a different approval model.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-re-test-showed-why-the-underlying-control-matters">The re-test showed why the underlying control matters<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#the-re-test-showed-why-the-underlying-control-matters" class="hash-link" aria-label="Direct link to The re-test showed why the underlying control matters" title="Direct link to The re-test showed why the underlying control matters" translate="no">​</a></h3>
<p>I wrote that <code>Review</code> was the one control that prevented writes after testing it once. It was not. Repeating the scaled-to-zero fault twice showed the supervised agent, ARM <code>mode: Review</code>, performing the scale operation without approval.</p>
<p>The agent's incident telemetry said <code>IncidentMitigatedByAgent: False</code> both times. Kubernetes audit logs were the only reliable witness: <code>kube-audit-admin</code> named the principal that executed the <code>PATCH .../scale</code> request. Treat that discrepancy as release-specific behaviour to verify in your tenant, not as a supported guarantee either way.</p>
<p>The cause was a hook named <code>require-approval-for-restarts</code> whose content effectively said: deny unless the action is confined to the namespace, reversible, evidence-backed, and not a delete. It did not check whether a human had approved anything. The agent was grading its own homework.</p>
<p>I rewrote the hook to unconditionally deny and re-tested with a fresh fault injection. The autonomous agent performed the fix; the supervised agent investigated and touched nothing. One clean run is evidence, not a guarantee, so keep testing the layer underneath the telemetry.</p>
<p><img decoding="async" loading="lazy" alt="The premium-tier incident as a two-act timeline: the live incident, followed by the deliberate RBAC fix and clean retry" src="https://luke.geek.nz/assets/images/premium-incident-timeline-afd5a295026b705edbbb8afb261d1353.svg" width="1372" height="446" class="img_ev3q"></p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="test-the-experience-not-only-the-configuration">Test the experience, not only the configuration<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#test-the-experience-not-only-the-configuration" class="hash-link" aria-label="Direct link to Test the experience, not only the configuration" title="Direct link to Test the experience, not only the configuration" translate="no">​</a></h2>
<ul>
<li class="">An enabled monitoring addon does not enable collection. <code>omsAgent.enabled</code> deploys the agent; a data collection rule makes it collect. Without the DCR, Container Insights is empty and nothing reports an error. Managed Prometheus has the same DCE/DCR/DCRA trap.</li>
<li class="">Absent data makes burn-rate alerts stop firing. A zero denominator produces <code>NaN</code>, and <code>NaN &gt; threshold</code> is false. Alert explicitly on absence with <code>absent()</code>.</li>
<li class="">Incident titles reach the agent URL-encoded. <code>availability baseline alert</code> becomes <code>availability%20baseline%20alert</code>, so a plan matching spaces never matches. Name alerts without spaces.</li>
<li class="">Overlapping response plans disable the agent. Several matching plans created incidents that were marked handled within a minute while running zero tools. One matching plan produced 31 tool calls and a successful remediation.</li>
</ul>
<p>A query returning zero rows and a query that can never work are indistinguishable. Establish a positive control before believing an empty result. More broadly, test the experience you intend to protect: inject a fault, observe the customer-facing signal, check the alert context, and verify the action or refusal in the underlying audit trail.</p>
<p><img decoding="async" loading="lazy" alt="Diagram showing where the monitoring and SLI identity roles must be assigned, including the distinction between resource-group and Service Group scope" src="https://luke.geek.nz/assets/images/gotcha2-dcr-rbac-7f4c56d63188e292c3d3296b96a970c2.svg" width="1166" height="487" class="img_ev3q"></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="make-reliability-legible-to-decision-makers">Make reliability legible to decision-makers<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#make-reliability-legible-to-decision-makers" class="hash-link" aria-label="Direct link to Make reliability legible to decision-makers" title="Direct link to Make reliability legible to decision-makers" translate="no">​</a></h3>
<p>The platform engineer wanted a single pane connecting infrastructure state to user reliability. For most of this project that was two panes: pod health in Log Analytics with KQL, and SLI evaluations in the Azure Monitor Workspace with PromQL.</p>
<p>Workbooks support Prometheus as a data source. The JSON shape is undocumented, but the portal's own workbook uses this form:</p>
<div class="language-jsonc codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsonc codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">{</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "queryType": 16,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "resourceType": "microsoft.monitor/accounts",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "crossComponentResources": ["&lt;azure monitor workspace resource id&gt;"],</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  "query": "{\"version\":\"PrometheusQueryProvider/1.0\",\"queryText\":\"slo:error_budget:remaining_ratio\",\"type\":\"query_range\"}",</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">}</span><br></div></code></pre></div></div>
<p>The composite panel uses the minimum attainment across every SLI rather than an average. A premium-tier outage should read “we are breaching our worst commitment”, not disappear inside a blended number.</p>
<p><img decoding="async" loading="lazy" alt="The composite panel showing the minimum attainment across every SLI, then the per-SLI table identifying tier-availability-premium as the series driving it" src="https://luke.geek.nz/assets/images/composite-panel-fixed-bbba816cdc576b96aa7587dad3493bcc.gif" width="1494" height="782" class="img_ev3q"></p>
<p>That panel also closes the reporting gap for the engineering manager, who needs credible error-budget reporting but is least likely to browse nine SLI charts. Alerting on a number is not the same as being able to see its trend, and a trend without an agreed decision is not a useful outcome either.</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-it-still-does-not-do">What it still does not do<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#what-it-still-does-not-do" class="hash-link" aria-label="Direct link to What it still does not do" title="Direct link to What it still does not do" translate="no">​</a></h3>
<ul>
<li class="">Service Groups are still in public preview. Do not make a production security or compliance boundary depend on Service Group membership, and confirm the current preview terms before adopting the pattern broadly.</li>
<li class="">An SLI's definition is portable, but its execution is not permission-free. The managed identity, source and destination workspaces, default data collection rule, and metric collection path all need to be healthy.</li>
<li class="">The in-cluster SLIs still measure in-cluster traffic. <code>external-availability</code> covers the public path; the rest are blind to ingress and DNS by construction.</li>
<li class="">The tier SLA table is a draft, not a signed contract. Differentiated targets exist and alert independently, but nothing enforces the SLA outside that policy document.</li>
<li class="">The one-day compliance window is a demo choice so the budget visibly moves. Never copy <code>evaluationPeriodDays: 1</code> into production without an explicit decision.</li>
<li class="">Native SLI alerts and explicit PromQL metric alerts are two policy paths. Choose one owner for each condition, or document why duplicate alert routes are useful.</li>
<li class="">SLOs do not detect outages faster than a probe. On the same outage, the external web test fired in 3m37s and the burn-rate alert in roughly five minutes. SLOs earn their place in the ambiguous middle: deciding whether a 2% error rate is worth waking someone for.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-reliability-design-checklist">A reliability design checklist<a href="https://luke.geek.nz/azure/monitor-slis-service-groups-sre-agent-aks/#a-reliability-design-checklist" class="hash-link" aria-label="Direct link to A reliability design checklist" title="Direct link to A reliability design checklist" translate="no">​</a></h2>
<p>Start with the user's task and the business promise. Measure the user, not the application. Ask what moves if DNS breaks. If none of the signals move, you have instrumentation, not an SLO.</p>
<p>Choose dimensions that support a decision. Customer tier, journey, region, and tenant can explain impact, but uncontrolled cardinality can make the signal expensive and noisy.</p>
<p>Derive alerts from the SLI, never alongside it. A parallel implementation will drift silently toward being less sensitive than the SLO it enforces, and it will pass testing because total outages trip almost anything.</p>
<p>Design the handoff for the person or agent who must act. Carry the service identity, customer impact, target, and evidence into the incident instead of exposing only a rule name.</p>
<p>Verify guardrails by trying to breach them. Hooks that match nothing, plan modes that do not supervise, and rules that never evaluate all look identical to ones that work. The difference is visible only when you inject a fault and watch what happens.</p>
<p>Finally, treat reliability as a learning loop. Observe the customer experience, interpret the evidence with the people who own the outcome, test the smallest safe intervention, and update the design when reality disagrees with the model.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_BuS1"><p>The repository, including the Bicep, fault-injection scripts, and agent configuration, is public: <a href="https://github.com/lukemurraynz/AzureSLI-AzureSREAgent" target="_blank" rel="noopener noreferrer" class="">lukemurraynz/AzureSLI-AzureSREAgent</a>.</p></div></div>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Strangler fig: retiring a legacy dispatch layer]]></title>
            <link>https://luke.geek.nz/misc/incremental-legacy-dispatch-migration/</link>
            <guid>https://luke.geek.nz/misc/incremental-legacy-dispatch-migration/</guid>
            <pubDate>Tue, 04 Aug 2026 08:37:19 GMT</pubDate>
            <description><![CDATA[Apply the strangler fig pattern to retire a legacy dispatch layer: domain-by-domain migration, route parity tests, ported tests, and safe cutovers.]]></description>
            <content:encoded><![CDATA[<p>Every codebase that has lasted long enough builds up at least one layer like this. Request stubs call into a wiring dict: a lookup table of small lambda functions, one per route, that exists only to find the right handler. Those lambdas call translation functions that move data between an old dispatch shape and what the framework now wants on its own. It worked when it was written. Every new feature since has paid a cost: it goes through three extra steps to do something the framework could do directly if you let it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-shape-of-the-problem">The shape of the problem<a href="https://luke.geek.nz/misc/incremental-legacy-dispatch-migration/#the-shape-of-the-problem" class="hash-link" aria-label="Direct link to The shape of the problem" title="Direct link to The shape of the problem" translate="no">​</a></h2>
<!-- -->
<p>Three extra steps sit between "a request arrived" and "code that does something with it." None of the three add behaviour. All three exist because, at some point, the framework's built-in routing did not do what the app needed yet. That was the fastest way to bridge the gap. Years later, the framework has caught up. But hundreds of routes still depend on that bridge, and nobody wants to be the one who breaks it.</p>
<p>"Just rewrite it" is the answer everyone reaches for. It is also the answer almost nobody can actually ship. A rewrite means a feature freeze long enough to matter. It also means one huge merge that is almost impossible to review well. And it creates a regression surface that spans the entire app instead of one corner of it. The option that actually ships is one domain at a time.</p>
<p>That option already has a name, so use it instead of acting like it is new. Martin Fowler wrote about it on martinfowler.com in 2004 as the <a href="https://martinfowler.com/bliki/StranglerFigApplication.html" target="_blank" rel="noopener noreferrer" class="">strangler fig application</a>. The name comes from the way a strangler fig seed grows high in a host tree's branches. Over years, it sends roots down around the trunk until the original tree is no longer holding anything up. Build the new path next to the old one. Move traffic to it one piece at a time. Retire the old piece as nothing depends on it anymore. The general shape is Fowler's. What is specific to a legacy HTTP dispatch layer is the fixed rule set below. It shows what a strangler fig migration looks like when the thing you are strangling is three layers of route-dispatch extra steps in front of hundreds of endpoints.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-migration-rules">The migration rules<a href="https://luke.geek.nz/misc/incremental-legacy-dispatch-migration/#the-migration-rules" class="hash-link" aria-label="Direct link to The migration rules" title="Direct link to The migration rules" translate="no">​</a></h2>
<ol>
<li class=""><strong>Freeze the current list of routes as a baseline before changing anything:</strong> add a test that checks the current set of registered routes. Then any drop or duplicate during migration fails loudly instead of shipping silently. This one test is what makes everything after it safe. You cannot check "nothing changed for the caller" without first writing down what "unchanged" means.</li>
<li class=""><strong>Move one domain's handlers to the framework's built-in pattern for each pass.</strong> Not the whole app. Pick one clear slice, such as a single API domain (orders, billing, or whatever your app's real seams are). Replace it with native routers and typed request and response schemas. Share one dependency-injection context instead of spreading ad hoc lookups through the handler.</li>
<li class=""><strong>Port the old tests to the new transport, don't write them from scratch.</strong> The current test suite shows the real behaviour, edge cases and all. Those cases often came from real incidents. Writing tests from a blank page means finding those edge cases the hard way, in production, again. Porting a dispatch-based test to hit the new HTTP layer directly keeps the same contract while changing the mechanism underneath it. Keep the same inputs and the same status codes and payloads. Only the transport changes.</li>
<li class=""><strong>Delete the old domain's legacy modules as soon as the cutover is done.</strong> Not "leave them for now in case we need to roll back." The moment the new path is checked and merged, remove the old wiring for that domain in the same change. Two ways to do the same thing are worse than either way alone. A bug fix now has to be made in both places, or it silently does not.</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-the-actual-time-goes">Where the actual time goes<a href="https://luke.geek.nz/misc/incremental-legacy-dispatch-migration/#where-the-actual-time-goes" class="hash-link" aria-label="Direct link to Where the actual time goes" title="Direct link to Where the actual time goes" translate="no">​</a></h2>
<p>The router code itself is close to mechanical. The time sink is everywhere the old and new paths disagree about details nobody wrote down:</p>
<ul>
<li class=""><strong>Status code mapping:</strong> the old dispatch shim might turn a permission error into a 403 and a value error into a 400 through some central error handler. The new built-in router needs the same mapping. If it does not, clients that depend on specific status codes break. That can be easy to miss in a quick manual check and obvious the moment a real client hits it.</li>
<li class=""><strong>Test harness behaviour that hides real errors:</strong> a test client that swallows server exceptions by default can report a clean pass on a handler that is actually throwing a 500. The framework caught the exception before your assertion ever saw the status code. Get this wrong and "port the tests" from rule 3 quietly stops testing anything.</li>
<li class=""><strong>Request shape differences:</strong> a GET that used to pass parameters through a dispatch-layer body now needs those parameters in the query string to fit the framework more cleanly. Each difference is small. There are a lot of them.</li>
</ul>
<p>A design doc never catches this. A failing test does, one at a time. That is why a domain-by-domain migration matters. A failing test in a 30-route migration tells you something specific. A failing test in a 400-route one huge rewrite tells you almost nothing about where to look.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-rule-set-adds-to-the-general-pattern">What this rule set adds to the general pattern<a href="https://luke.geek.nz/misc/incremental-legacy-dispatch-migration/#what-this-rule-set-adds-to-the-general-pattern" class="hash-link" aria-label="Direct link to What this rule set adds to the general pattern" title="Direct link to What this rule set adds to the general pattern" translate="no">​</a></h2>
<p>The general case for a strangler fig migration over a rewrite is well known: ship value steadily, avoid a feature freeze, and catch regressions in a scope small enough to debug. What this rule set adds for a dispatch-layer migration is specific. The baseline from rule 1 turns each migration into a change you can check with a test, not just a smaller one. You are not asking a reviewer to trust that "the behaviour is the same." You are pointing to a test that proves it. That test fails the build the moment parity breaks. This scales in a way careful review never does, and it misses nothing the test actually covers.</p>
<p>None of this is tied to one framework. None of it requires treating the strangler fig approach as a new idea. It is the discipline that makes "boring, steady, and safe" beat "fast, exciting, and scary" when the thing you are strangling is a dispatch layer that hundreds of routes depend on.</p>]]></content:encoded>
            <category>Misc</category>
        </item>
        <item>
            <title><![CDATA[Building Execution Ledger - Durable Workflow Orchestration in Rust on Azure Container Apps]]></title>
            <link>https://luke.geek.nz/azure/execution-ledger/</link>
            <guid>https://luke.geek.nz/azure/execution-ledger/</guid>
            <pubDate>Tue, 04 Aug 2026 07:25:59 GMT</pubDate>
            <description><![CDATA[Learn how I built Execution Ledger, a durable workflow orchestrator in Rust running on Azure Container Apps with step-level replay, idempotency, compensation, KEDA autoscaling, and managed identity.]]></description>
            <content:encoded><![CDATA[<p><a href="https://github.com/lukemurraynz/Execution-Ledger" target="_blank" rel="noopener noreferrer" class="">Execution-Ledger</a> is an open-source durable workflow orchestrator I built in Rust on Azure Container Apps. It handles step-level idempotency, targeted replay, and compensation for long-running jobs that span external systems. This post is about why I chose Rust, why I chose ACA, and what I learned along the way.</p>
<p>I’ve spent a lot of time around orchestration frameworks. Azure Durable Functions doesn’t automatically rewind entire orchestrations when a single step fails; instead, the orchestrator fails and previous steps remain executed, with "rewind" only available as a manual, preview‑level recovery that replays from the failure point. Temporal demands a Kubernetes cluster and a team comfortable with its programming model. And then there’s the dark matter: the cron scripts and PowerShell scheduled tasks that run half the back offices I’ve seen, failing silently at 3am until someone notices the queue depth.</p>
<p>I wanted something that treated recovery as a first-class capability rather than an operational afterthought. Something where an operator could replay <em>one step</em> without touching the rest of the job. Something where compensation wasn't an afterthought but a first-class primitive. Something that ran on Azure's managed platform without requiring Kubernetes, and used managed identity everywhere because I am done explaining connection strings to auditors.</p>
<p>So I built Execution Ledger. It's a Rust multi-service backend deployed on Azure Container Apps, and this post is about why.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-gap-step-level-recovery-as-a-first-class-primitive">The gap: step-level recovery as a first-class primitive<a href="https://luke.geek.nz/azure/execution-ledger/#the-gap-step-level-recovery-as-a-first-class-primitive" class="hash-link" aria-label="Direct link to The gap: step-level recovery as a first-class primitive" title="Direct link to The gap: step-level recovery as a first-class primitive" translate="no">​</a></h2>
<p>Most orchestration platforms treat replay as an all-or-nothing operation. Durable Functions rewinds the orchestration to a checkpoint, every step after that point re-executes. Temporal uses continue-as-new to re-run the entire workflow. Manual scripts? Hope your operator knows SQL.</p>
<p>The problem is that in real multi-step jobs, most step failures are surgical. The REST call to the ERP timed out because the ERP was under maintenance. The blob upload failed because a SAS token expired. Re-running every other step, steps that already committed, is wasteful at best and dangerous at worst.</p>
<p>Execution Ledger gives each connector step an idempotency key (derived from run ID + step ID) and lets you target replay at a specific step. Other steps in the run are untouched.</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token function" style="color:rgb(80, 250, 123)">cargo</span><span class="token plain"> run </span><span class="token parameter variable" style="color:rgb(189, 147, 249);font-style:italic">-p</span><span class="token plain"> execution-ledger-cli -- job-replay </span><span class="token operator">&lt;</span><span class="token plain">tenant-id</span><span class="token operator">&gt;</span><span class="token plain"> </span><span class="token operator">&lt;</span><span class="token plain">run-id</span><span class="token operator">&gt;</span><span class="token plain"> health</span><br></div></code></pre></div></div>
<p>That's it. One step. The worker re-executes only <code>health</code>. The other steps (the blob transfer, the queue message, the notification) stay exactly as they were. This makes incident recovery precise rather than all-or-nothing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-rust">Why Rust<a href="https://luke.geek.nz/azure/execution-ledger/#why-rust" class="hash-link" aria-label="Direct link to Why Rust" title="Direct link to Why Rust" translate="no">​</a></h2>
<p>Three reasons, in descending order of importance:</p>
<p><strong>1. No cold starts.</strong> Container Apps scale from zero. When a Durable Functions app wakes up after inactivity, C# JIT compiles, JavaScript loads modules, Python imports libraries. Rust compiles to a native binary. Startup is measured in milliseconds, not seconds. When KEDA triggers a scale-out from 1 to 10 replicas because the Service Bus queue just spiked, every new replica starts processing intents immediately.</p>
<p><strong>2. Resource density.</strong> A Rust binary serving HTTP with Axum at idle consumes single-digit megabytes of RAM. That matters in Container Apps where you pay per vCPU-second and per GB-second. At 10 worker replicas, the memory delta between Rust and a managed runtime pays for itself monthly.</p>
<p><strong>3. A compiler that actually enforces things.</strong> The workspace denies <code>unwrap()</code>, <code>expect()</code>, and <code>panic!()</code> via clippy lints at the crate level. Unsafe code is forbidden. <code>clippy::pedantic</code> is set to warn. These aren't style preferences. They're guardrails. When you build a platform that processes tenant-scoped jobs with Entra-authenticated callers, you want the compiler catching mistakes before they reach production.</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)">// ❌ won't compile — clippy::unwrap_used is deny</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">let</span><span class="token plain"> config </span><span class="token operator">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">load_config</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">unwrap</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">// ✅ must handle the error path</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">let</span><span class="token plain"> config </span><span class="token operator">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">load_config</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">context</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"failed to load runtime config"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token operator">?</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-architecture">The architecture<a href="https://luke.geek.nz/azure/execution-ledger/#the-architecture" class="hash-link" aria-label="Direct link to The architecture" title="Direct link to The architecture" translate="no">​</a></h2>
<p>Execution Ledger runs as four Container Apps behind API Management:</p>
<!-- -->
<!-- -->
<p>The worker is the interesting piece. It uses a tokio semaphore for per-replica bounded concurrency (default 5 concurrent intents). KEDA monitors Service Bus queue depth and scales replicas. Combined, you get ~50 concurrent intent executions at the default ceiling, and you can scale higher with max replicas or the concurrency knob.</p>
<p>Steps within a single intent execute sequentially. This is by design: step 3 might need the file path step 2 produced. Cross-run parallelism comes from horizontal scaling via replicas.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-azure-container-apps">Why Azure Container Apps<a href="https://luke.geek.nz/azure/execution-ledger/#why-azure-container-apps" class="hash-link" aria-label="Direct link to Why Azure Container Apps" title="Direct link to Why Azure Container Apps" translate="no">​</a></h2>
<p>I evaluated three deployment targets:</p>
<table><thead><tr><th></th><th>Container Apps</th><th>AKS (Standard)</th><th>Azure Functions</th></tr></thead><tbody><tr><td>Rust support</td><td>Docker container (native)</td><td>Docker container (native)</td><td>Custom handler only</td></tr><tr><td>KEDA integration</td><td>Built-in</td><td>Manual install</td><td>Built-in (but C#/JS/Python only)</td></tr><tr><td>Managed identity</td><td>Built-in</td><td>Workload identity via pod identity</td><td>Built-in</td></tr><tr><td>Cold start</td><td>Fast (native binary)</td><td>Fast (native binary)</td><td>Can't run Rust functions natively</td></tr><tr><td>Ops burden</td><td>Near zero</td><td>Persistent</td><td>Near zero</td></tr></tbody></table>
<p>Container Apps won because KEDA scale rules, managed identity, and private networking come out of the box. The <code>azd</code> integration (<code>azure.yaml</code> at the repo root) means the entire infrastructure (Service Bus, PostgreSQL Flexible Server, Key Vault, App Configuration, APIM, four Container Apps, private DNS zones, Log Analytics) provisions with one command:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">azd provision </span><span class="token operator">&amp;&amp;</span><span class="token plain"> azd deploy</span><br></div></code></pre></div></div>
<p>The <a href="https://drasi.io/" target="_blank" rel="noopener noreferrer" class="">Drasi</a> service runs as a singleton because PostgreSQL logical replication slots are inherently single-consumer. The scheduler runs as a singleton because it only enqueues intents; worker replicas do the real work. Both are Container Apps with <code>maxReplicas: 1</code> in the Bicep template, not special infrastructure.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-not-durable-functions">Why not Durable Functions?<a href="https://luke.geek.nz/azure/execution-ledger/#why-not-durable-functions" class="hash-link" aria-label="Direct link to Why not Durable Functions?" title="Direct link to Why not Durable Functions?" translate="no">​</a></h2>
<p>Execution Ledger isn't trying to replace Azure Durable Functions.</p>
<p>Durable Functions is excellent for event-driven serverless workflows where the orchestration model fits your problem.</p>
<p>Execution Ledger solves a different problem: operational recovery. Every connector step is independently replayable, idempotent, and compensatable. Instead of restarting an orchestration from a checkpoint, operators can replay exactly the failed step while preserving the rest of the execution history.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-i-learned">What I learned<a href="https://luke.geek.nz/azure/execution-ledger/#what-i-learned" class="hash-link" aria-label="Direct link to What I learned" title="Direct link to What I learned" translate="no">​</a></h2>
<p><strong>KEDA on Service Bus works beautifully for Rust workloads.</strong> Each replica competes for messages via <code>ServiceBusReceiverClient</code>. No coordination needed. The queue depth threshold of 25 messages per replica provides predictable scaling without flapping.</p>
<p><strong>PostgreSQL connection pool sizing deserves its own section of the README.</strong> With N worker replicas each opening a pool, you hit <code>max_connections</code> faster than you think. Azure PostgreSQL Flexible Server B1ms caps at 97 connections. With 10 workers at a steady-state ~7 connections each + 3 API + 1 scheduler + 1 Drasi = 75. Leave headroom.</p>
<p><strong>App Configuration with Key Vault references is the right config pattern.</strong> Bootstrap env vars carry only the App Configuration endpoint and environment label. Everything else (queue names, timeouts, toggles) lives in App Configuration by label. Key Vault references (<code>&lt;secret:my-secret&gt;</code>) resolve at startup via managed identity. The process fails closed if any required secret is missing. No connection strings. No access keys. No shared secrets in env vars.</p>
<p><strong>The doc-comment-as-specification pattern works for Rust connector code.</strong> Every connector module has a <code>//!</code> header explaining its configuration contract, fail-closed behaviour, and compensation model. Here's the <code>rest</code> connector header:</p>
<div class="language-rust codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-rust codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)">//! REST and ERP connectors.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//!</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! | Key | Source | Required | Default | Notes |</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! |-----|--------|----------|---------|-------|</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! | `url` | config or input | yes | — | Target URL |</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! | `method` | config or input | no | `POST` | HTTP method |</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! | `timeoutMs` | config or input | no | `30000` | Clamped to 120s max |</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! | `headers` | config | no | — | Custom headers (some denylisted) |</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//!</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! ## Fail-closed behavior</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! - Returns `InvalidConfiguration` if `url` is missing.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! - Returns `InvalidConfiguration` if any denylisted header is present.</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">//! - Returns `DependencyFailed` if the HTTP request fails.</span><br></div></code></pre></div></div>
<p>This doubles as documentation and as a specification for anyone adding a new connector.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="is-this-for-you">Is this for you?<a href="https://luke.geek.nz/azure/execution-ledger/#is-this-for-you" class="hash-link" aria-label="Direct link to Is this for you?" title="Direct link to Is this for you?" translate="no">​</a></h2>
<p>Execution Ledger fits when you need durable, auditable job orchestration with built-in recovery controls. It's not for serverless event-driven workflows (use Durable Functions) or complex long-running workflows with custom activity patterns (use Temporal). It's for teams running ERP integrations, queue-based processing, blob transfers, or REST orchestration that need to survive failures without manual intervention, and who want the platform to prove it can recover before the first 3am page.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>info</div><div class="admonitionContent_BuS1"><p>The repo is open source at <a href="https://github.com/lukemurraynz/Execution-Ledger" target="_blank" rel="noopener noreferrer" class="">github.com/lukemurraynz/Execution-Ledger</a>. Dual-licensed Apache 2.0 / MIT. Contributions welcome.
<strong>Try it in 5 minutes:</strong> Clone the repo, open in VS Code, choose Reopen in Container. The devcontainer provisions a local Postgres with everything pre-wired. Then <code>cargo run -p execution-ledger-cli -- db-status</code> and you're off.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/execution-ledger/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://github.com/lukemurraynz/Execution-Ledger" target="_blank" rel="noopener noreferrer" class="">Execution Ledger on GitHub</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/container-apps/?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Container Apps documentation</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/container-apps/scale-app/?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">KEDA scalers for Azure</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/postgresql/flexible-server/?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure PostgreSQL Flexible Server</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Treat prompt changes like code deploys]]></title>
            <link>https://luke.geek.nz/azure/eval-gates-for-prompts/</link>
            <guid>https://luke.geek.nz/azure/eval-gates-for-prompts/</guid>
            <pubDate>Wed, 29 Jul 2026 06:37:14 GMT</pubDate>
            <description><![CDATA[Prevent silent regressions by treating prompt edits like code deploys, with automated tests, CI gating, and continuous evaluation.]]></description>
            <content:encoded><![CDATA[<p>Most teams running LLM-backed features would never ship a code change without a test suite and a CI gate. Then they tweak a prompt, eyeball one output, and send it straight to production.</p>
<p>A prompt is still a dependency. Change it, and downstream behaviour can shift with no compiler warning, no failing build, and no obvious signal in the request pipeline.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="why-this-fails-quietly">Why this fails quietly<a href="https://luke.geek.nz/azure/eval-gates-for-prompts/#why-this-fails-quietly" class="hash-link" aria-label="Direct link to Why this fails quietly" title="Direct link to Why this fails quietly" translate="no">​</a></h2>
<p>A bad code change usually throws, times out, or fails a test. A bad prompt change degrades tone, drifts off format, starts hallucinating in a new way, or gets subtly less accurate, and every one of those failure modes returns a valid HTTP 200 with plausible-looking text. Nothing in a typical request/response cycle distinguishes a good prompt output from a degraded one. The only way to catch it is to check the output against something, before a customer does.</p>
<p>That is the gap an eval gate closes: a required check between "someone edited a prompt" and "that prompt is what production calls", in the same way CI sits between "someone pushed a commit" and "that commit is what is running".</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-pattern">The pattern<a href="https://luke.geek.nz/azure/eval-gates-for-prompts/#the-pattern" class="hash-link" aria-label="Direct link to The pattern" title="Direct link to The pattern" translate="no">​</a></h2>
<!-- -->
<p>A few design choices make this work rather than become theater:</p>
<ul>
<li class="">Fail closed: if no eval run exists yet for a prompt version, the gate blocks by default. "Assume it is fine because nobody checked" is exactly the failure mode this is meant to prevent.</li>
<li class="">Check the latest run, not any run: a prompt that passed three edits ago does not get to ride on that old pass.</li>
<li class="">Expose status without promotion: a read-only status check lets dashboards and operators inspect state without accidentally attempting promotion.</li>
<li class="">Return an actionable reason: "Blocked" is not enough. "Blocked: latest eval failed on 3/40 factuality cases" gives the author somewhere to start.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="where-this-sits-on-the-maturity-curve">Where this sits on the maturity curve<a href="https://luke.geek.nz/azure/eval-gates-for-prompts/#where-this-sits-on-the-maturity-curve" class="hash-link" aria-label="Direct link to Where this sits on the maturity curve" title="Direct link to Where this sits on the maturity curve" translate="no">​</a></h2>
<p>This is one rung on a ladder, not the whole ladder:</p>
<ol>
<li class=""><strong>No process:</strong> prompt changes go straight to production. Someone notices a regression from a support ticket or a screenshot.</li>
<li class=""><strong>Manual review:</strong> a second person reads the diff before it ships. Catches obvious problems, misses subtle ones, doesn't scale past a small team.</li>
<li class=""><strong>Automated eval gate:</strong> a fixed test set runs against every candidate prompt before promotion. Catches regressions against known cases, doesn't catch novel failure modes the test set doesn't cover.</li>
<li class=""><strong>Continuous eval in production:</strong> sample live traffic, run it back through evaluation, and feed regressions into the same test set that gates step 3. This is where the set gets stronger over time instead of freezing at whatever the team thought to write on day one.</li>
</ol>
<p>Most teams building on <a href="https://learn.microsoft.com/azure/foundry/what-is-foundry?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Microsoft Foundry</a> or a similar platform are somewhere between 1 and 2 today. Getting to 3 doesn't need custom infrastructure. It needs wiring together pieces the platform already gives you.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-steps-3-and-4-actually-look-like-on-microsoft-foundry">What steps 3 and 4 actually look like on Microsoft Foundry<a href="https://luke.geek.nz/azure/eval-gates-for-prompts/#what-steps-3-and-4-actually-look-like-on-microsoft-foundry" class="hash-link" aria-label="Direct link to What steps 3 and 4 actually look like on Microsoft Foundry" title="Direct link to What steps 3 and 4 actually look like on Microsoft Foundry" translate="no">​</a></h2>
<p>Worth being precise here, because it is easy to overstate what is automatic.</p>
<p>Versioning is the unit of promotion, not the prompt text itself. Every save of a prompt-based agent in Foundry creates an immutable version. You route traffic to a specific version and roll back to a prior one if the new one misbehaves. Promotion is traffic moving from version N to N+1. Rollback is moving it back.</p>
<p>The evaluators are built in, not something you write from scratch. Foundry ships evaluators that map directly to common failure modes: Groundedness and Relevance for RAG-style factual drift, Coherence and Fluency for format and tone drift, Intent Resolution / Tool Call Accuracy / Task Adherence for agentic behaviour, and risk and safety evaluators (hate/unfairness, sexual, violence, self-harm, prompt injection) for the failure modes that matter far more than "the wording is a bit off".</p>
<p>The practical CI gate mechanism is a GitHub Action, and it is honest about uncertainty. The <a href="https://github.com/microsoft/ai-agent-evals" target="_blank" rel="noopener noreferrer" class=""><code>microsoft/ai-agent-evals</code> action (preview)</a> takes a candidate version and a baseline version in <code>agent-name:version</code> form, runs both against your dataset with the evaluators you name, and returns scores with confidence intervals and significance testing. That is the difference between "up by 0.02" (likely noise) and "significantly better at p&lt;0.05" (likely real).</p>
<p>The caveat is important: there is no single "promote" API that the platform automatically blocks on your own score threshold. Promotion in Foundry today is a human or CI decision informed by comparison output. So "fail closed" is still your pipeline logic. Do not merge, deploy, or re-route traffic unless the evaluation step passed.</p>
<p>Step 4, continuous evaluation, is largely an observability workflow, not a separate product build. Once a version is live, Foundry can sample production traffic and traces on a recurring schedule, then score with the same evaluators. Same regression question, now asked continuously.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-trap-worth-naming-the-gate-becoming-a-bottleneck-teams-route-around">The trap worth naming: the gate becoming a bottleneck teams route around<a href="https://luke.geek.nz/azure/eval-gates-for-prompts/#the-trap-worth-naming-the-gate-becoming-a-bottleneck-teams-route-around" class="hash-link" aria-label="Direct link to The trap worth naming: the gate becoming a bottleneck teams route around" title="Direct link to The trap worth naming: the gate becoming a bottleneck teams route around" translate="no">​</a></h2>
<p>A gate that is slow, opaque, or noisy on legitimate changes gets bypassed. Someone finds the emergency override, uses it "just this once", and six months later the override is the normal path and the gate is decoration. Three things help prevent that:</p>
<ul>
<li class="">Fast eval loops: if checking a candidate takes twenty minutes, people batch changes and avoid the gate. Minutes, not tens of minutes, for the common path.</li>
<li class="">Clear failure output: a pass/fail flag with no detail teaches people to distrust the gate instead of fixing the prompt.</li>
<li class="">An audited escape hatch: emergencies happen. The override should exist, but it should be logged, attributed, and visible.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="treat-the-prompt-like-the-artifact-it-is">Treat the prompt like the artifact it is<a href="https://luke.geek.nz/azure/eval-gates-for-prompts/#treat-the-prompt-like-the-artifact-it-is" class="hash-link" aria-label="Direct link to Treat the prompt like the artifact it is" title="Direct link to Treat the prompt like the artifact it is" translate="no">​</a></h2>
<p>Treat a prompt exactly like any other artefact that can change production behaviour without a compiler catching mistakes. Give it a test set, give it a gate, and default that gate to blocking until proven otherwise.</p>
<p>Hopefully this helps you avoid the quiet regressions that only show up after customers do, and gives you a practical path to move from manual review to repeatable evaluation gates.</p>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Avoiding Reasoning Model Failures with Microsoft Foundry]]></title>
            <link>https://luke.geek.nz/azure/reasoning-model-integration-traps/</link>
            <guid>https://luke.geek.nz/azure/reasoning-model-integration-traps/</guid>
            <pubDate>Wed, 29 Jul 2026 06:24:15 GMT</pubDate>
            <description><![CDATA[Learn four ways reasoning-tier models can break Azure OpenAI integrations and how to fix API versioning, params, token budgets, and reasoning_effort.]]></description>
            <content:encoded><![CDATA[<p>Swapping only the deployment name is not a safe change for reasoning-tier models. Even with the same endpoint and SDK, reasoning deployments (o-series, GPT-5+) can fail in at least four different ways, and you usually only see them under real traffic.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-the-api-surface-itself-might-change-under-you">1. The API surface itself might change under you<a href="https://luke.geek.nz/azure/reasoning-model-integration-traps/#1-the-api-surface-itself-might-change-under-you" class="hash-link" aria-label="Direct link to 1. The API surface itself might change under you" title="Direct link to 1. The API surface itself might change under you" translate="no">​</a></h2>
<p>Azure OpenAI currently has two versioning surfaces. The legacy route uses dated strings like <code>POST {endpoint}/openai/responses?api-version=2025-04-01-preview</code>. The newer route, <code>/openai/v1/responses</code>, is designed to remove that treadmill. In preview, it expects the literal <code>api-version=preview</code> rather than a date. At GA, <code>api-version</code> is no longer required.</p>
<p>If your client or agent framework moved to <code>/v1/</code> but your config still sends a dated legacy value, you get <code>400 API version not supported</code>. The value can look reasonable while still being wrong for that route.</p>
<p>Two checks matter here. First, rollout behavior is not fully uniform: some resources have accepted a specific dated preview value (<code>2025-11-15-preview</code>) instead of literal <code>preview</code>. Second, <code>/openai/v1/...</code> only exists when the "Next-generation APIs (v1 preview)" feature is enabled on the resource. If it is not enabled, you get a 404 instead of a version error.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-sampling-parameters-your-code-has-always-sent">2. Sampling parameters your code has always sent<a href="https://luke.geek.nz/azure/reasoning-model-integration-traps/#2-sampling-parameters-your-code-has-always-sent" class="hash-link" aria-label="Direct link to 2. Sampling parameters your code has always sent" title="Direct link to 2. Sampling parameters your code has always sent" translate="no">​</a></h2>
<p>Reasoning models don't accept the sampling parameters chat models take for granted:</p>
<ul>
<li class=""><code>temperature</code></li>
<li class=""><code>top_p</code></li>
<li class=""><code>presence_penalty</code></li>
<li class=""><code>frequency_penalty</code></li>
<li class=""><code>logprobs</code> / <code>top_logprobs</code></li>
<li class=""><code>logit_bias</code></li>
<li class=""><code>max_tokens</code> (superseded by <code>max_completion_tokens</code> or <code>max_output_tokens</code>, see below)</li>
</ul>
<p>Code that hardcodes <code>temperature=0.7</code> because older deployments accepted it will fail against reasoning-tier models. There is no graceful fallback. The request errors.</p>
<p>The durable fix is to make sampling parameters conditional, not assumed. Send your normal set, and if the model rejects a non-essential field, drop it and retry.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-reasoning-tokens-draw-from-your-visible-output-budget">3. Reasoning tokens draw from your visible-output budget<a href="https://luke.geek.nz/azure/reasoning-model-integration-traps/#3-reasoning-tokens-draw-from-your-visible-output-budget" class="hash-link" aria-label="Direct link to 3. Reasoning tokens draw from your visible-output budget" title="Direct link to 3. Reasoning tokens draw from your visible-output budget" translate="no">​</a></h2>
<p>This is the most confusing trap because it usually does not error. It just returns nothing.</p>
<p>Reasoning models spend tokens on internal reasoning before they produce any visible output. Those hidden reasoning tokens count against the same budget as the answer text: <code>max_completion_tokens</code> on the Chat Completions API, or <code>max_output_tokens</code> on the Responses API. Microsoft's own docs put it directly: <code>max_completion_tokens</code> is "an upper bound for the number of tokens that can be generated for a completion, including visible output tokens and reasoning tokens."</p>
<p>If you size that budget like a non-reasoning model, you can get <code>finish_reason: "length"</code> with an empty response. The model spent the whole allowance reasoning and never emitted visible text. This looks like a timeout or parser issue, which makes root cause hard to spot unless you inspect <code>completion_tokens_details.reasoning_tokens</code>.</p>
<p>The fix is a floor, not a formula. Reserve materially more headroom than visible output needs, and treat that as a hard minimum for reasoning-tier deployments.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-reasoning_effort-is-a-real-lever-and-its-defaults-vary-by-model">4. reasoning_effort is a real lever, and its defaults vary by model<a href="https://luke.geek.nz/azure/reasoning-model-integration-traps/#4-reasoning_effort-is-a-real-lever-and-its-defaults-vary-by-model" class="hash-link" aria-label="Direct link to 4. reasoning_effort is a real lever, and its defaults vary by model" title="Direct link to 4. reasoning_effort is a real lever, and its defaults vary by model" translate="no">​</a></h2>
<p><code>reasoning_effort</code> controls how long the model reasons before responding. Most reasoning models support <code>low</code>, <code>medium</code>, and <code>high</code>. GPT-5 models also add <code>minimal</code> for latency-sensitive paths. Check support per deployment:</p>
<ul>
<li class=""><code>gpt-5.1</code>, <code>gpt-5.2</code>, and <code>gpt-5.1-codex*</code> support <code>none</code>, which explicitly disables reasoning, but on <code>gpt-5.1</code> you have to pass it explicitly. Unlike earlier reasoning models, it does not default to any particular effort level.</li>
<li class=""><code>gpt-5-pro</code> only supports <code>high</code>, and defaults to it even if you omit the parameter.</li>
<li class=""><code>o1-mini</code> doesn't support <code>reasoning_effort</code> at all.</li>
<li class="">Parallel tool calls aren't supported when <code>reasoning_effort="minimal"</code>, which matters if your agent framework relies on parallel tool invocation.</li>
</ul>
<p>For latency-sensitive interactive paths, moving to <code>low</code> or <code>minimal</code> is often your largest latency lever. Set it explicitly per deployment rather than copying values across models in the same family.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-timeout-budget-these-interact-with">The timeout budget these interact with<a href="https://luke.geek.nz/azure/reasoning-model-integration-traps/#the-timeout-budget-these-interact-with" class="hash-link" aria-label="Direct link to The timeout budget these interact with" title="Direct link to The timeout budget these interact with" translate="no">​</a></h2>
<p>Reasoning latency must fit inside the timeout budget in front of your endpoint. In Azure Container Apps, default (non-premium) ingress timeout is fixed at 240 seconds and is not configurable. If you need longer, the supported option is premium ingress, where you can raise idle timeout (<code>--request-idle-timeout</code>) up to 30 minutes, with a 4-minute minimum. This is an idle timeout (no-activity window), not a hard total-duration cap, but it is the practical control that decides whether high-effort reasoning calls survive.</p>
<p>Before increasing <code>reasoning_effort</code> on an interactive path, verify what sits in front of the endpoint and whether that timeout budget matches real latency at the new effort level.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="a-short-checklist-before-swapping-in-a-reasoning-tier-model">A short checklist before swapping in a reasoning-tier model<a href="https://luke.geek.nz/azure/reasoning-model-integration-traps/#a-short-checklist-before-swapping-in-a-reasoning-tier-model" class="hash-link" aria-label="Direct link to A short checklist before swapping in a reasoning-tier model" title="Direct link to A short checklist before swapping in a reasoning-tier model" translate="no">​</a></h2>
<ul>
<li class="">Confirm which API surface your client or agent framework targets, and match <code>api-version</code> to that surface, not to habit.</li>
<li class="">Strip <code>temperature</code> and the other unsupported sampling parameters from reasoning-model call paths, or make them conditional.</li>
<li class="">Raise <code>max_completion_tokens</code> / <code>max_output_tokens</code> with explicit headroom for hidden reasoning tokens, and check <code>completion_tokens_details.reasoning_tokens</code> in a test call before assuming the budget is sized correctly.</li>
<li class="">Set <code>reasoning_effort</code> deliberately per deployment rather than inheriting a value from a different model in the family, and check whether your target model supports the value you're setting.</li>
<li class="">Check the ingress or gateway timeout in front of the endpoint against the latency a higher effort level will actually produce.</li>
</ul>
<p>They are easy to miss because older deployments often tolerated defaults that reasoning-tier deployments reject.</p>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[When Your AI Agent Lies: Silent LLM Fallbacks]]></title>
            <link>https://luke.geek.nz/azure/silent-llm-fallback/</link>
            <guid>https://luke.geek.nz/azure/silent-llm-fallback/</guid>
            <pubDate>Wed, 29 Jul 2026 05:48:30 GMT</pubDate>
            <description><![CDATA[Detect silent LLM fallbacks: when AI agents return templated responses with 200 OK, monitor token usage and fallback rates to avoid hidden correctness failures.]]></description>
            <content:encoded><![CDATA[<p>A system I was debugging had AI-generated content flowing through it for days. The responses came back fast. No errors in the logs. No alerts fired. Every single response was templated.</p>
<p>The fallback logic was intentional. If a model call failed, the system would return a deterministic template instead of showing the user an error. That works fine when failures are actually rare. But something had gone wrong. The fallback wasn't rare anymore - it was the only path that ever executed. And nobody was watching for it.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-slow-collapse">The slow collapse<a href="https://luke.geek.nz/azure/silent-llm-fallback/#the-slow-collapse" class="hash-link" aria-label="Direct link to The slow collapse" title="Direct link to The slow collapse" translate="no">​</a></h2>
<p>It wasn't one thing. There were three separate bugs, each independently harmless, each invisible on its own. Stack them together and everything - but it still looks fine from the outside.</p>
<!-- -->
<p><strong>The API version thing:</strong> Azure OpenAI migrated to a new request surface. The old path uses dated version strings like <code>api-version=2025-04-01-preview</code>. The new <code>/openai/v1/...</code> surface is different—it wants <code>api-version=preview</code> while it's in preview, and eventually the parameter goes away entirely. A client configured with the old dated string hitting the new surface gets a <code>400 API version not supported</code>. Except it doesn't fail loud. The exception gets caught and swallowed. The fallback fires.</p>
<p><strong>Then there was the parameter mismatch.</strong> Reasoning models (o-series, GPT-5, future ones) drop support for <code>temperature</code>, <code>top_p</code>, <code>presence_penalty</code>, and a bunch of other sampling params. Code that hardcodes <code>temperature=0.7</code> because that was always safe breaks immediately when the deployment switches to one of these models. Again: the request fails, gets caught, fallback executes.</p>
<p><strong>The third one was the config issue.</strong> The system used a prefix-trimming config loader. Useful feature: strip <code>App1/</code> off every key, so code queries <code>message</code> instead of the full <code>App1/message</code>. Except in this codebase, <em>every reader</em> was querying the full untrimmed key. So every read missed. Every read fell back to its hardcoded default. Model routing config, prompt versions, retry budgets—all silently reverted to defaults. No error, because a missing key with a default isn't treated as a failure.</p>
<p>Individually, any one would have been caught in testing. Together, they created a system that looked completely fine while doing none of what it was designed to do. The kind of bug you only catch by accident.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="how-it-stayed-invisible">How it stayed invisible<a href="https://luke.geek.nz/azure/silent-llm-fallback/#how-it-stayed-invisible" class="hash-link" aria-label="Direct link to How it stayed invisible" title="Direct link to How it stayed invisible" translate="no">​</a></h2>
<p>Each failure was caught and logged—at a verbosity level nobody monitors in production. The fallback was doing its job: catch the exception, return the template, keep the response flowing. From a monitoring dashboard, this looks perfect. 200s coming back, response times good, no error spikes.</p>
<p>Your observability is built to catch failures. Not wrongness. A system that breaks and admits it will get your attention. A system that breaks and lies will not.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-one-signal-that-mattered">The one signal that mattered<a href="https://luke.geek.nz/azure/silent-llm-fallback/#the-one-signal-that-mattered" class="hash-link" aria-label="Direct link to The one signal that mattered" title="Direct link to The one signal that mattered" translate="no">​</a></h2>
<p>Fast response time plus <code>total_tokens_used: 0</code> in the telemetry. That's the signature. Real inference against a reasoning model takes real time and burns real tokens. A lot of them. The model spends tokens on reasoning steps you never see. A fallback returns instantly and never touches the model. Zero tokens.</p>
<p>If you're logging token usage per request, this becomes a cheap way to distinguish "actual inference" from "template." Except you have to know to look.</p>
<p>Diagnosing this required getting into the running container and calling the agent manually with the prod credentials. The health check doesn't catch it. Once I fixed the API version problem and the actual model call went through, the system still wasn't working right. The config was still defaulting everything. That took another pass through the config loader to spot.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-id-do-differently">What I'd do differently<a href="https://luke.geek.nz/azure/silent-llm-fallback/#what-id-do-differently" class="hash-link" aria-label="Direct link to What I'd do differently" title="Direct link to What I'd do differently" translate="no">​</a></h2>
<p><strong>Validate at the config boundary.</strong> Wherever your client config meets the SDK, actually check that the values are legal for the SDK you're using. Not just syntactically valid—actually accepted by this version of this model. A version mismatch should fail at startup, loudly, not hide inside exception handling in production.</p>
<p><strong>Make fallback usage visible.</strong> Don't lump fallback executions into your generic error log. Emit a distinct counter every time it happens. Then alert on the <em>rate</em>. A system that falls back once a day is resilient. One that falls back 99% of the time and never mentioned it is busted.</p>
<p><strong>Log token usage and alert on zero.</strong> If your SDK exposes usage metrics, log them with every response. Set an alert threshold on responses that used zero tokens (outside of intentional cache hits). It's one of the cheapest correctness checks you can add.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-this-changes-about-well-architected">What this changes about well-architected<a href="https://luke.geek.nz/azure/silent-llm-fallback/#what-this-changes-about-well-architected" class="hash-link" aria-label="Direct link to What this changes about well-architected" title="Direct link to What this changes about well-architected" translate="no">​</a></h2>
<p>The standard resiliency review checks whether your system degrades gracefully. Necessary. Not sufficient for LLM-backed systems. Graceful degradation and silent correctness failure look identical on a dashboard that only tracks uptime and latency.</p>
<p>Add this question instead: <em>can this system be wrong in a way that looks fine?</em> For AI systems, that means: when a model call fails, does the fallback emit a signal? Is anyone actually watching it? Because a monitored fallback is resilience. An unmonitored one is just a way to be confidently wrong.</p>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[Microsoft Foundry hosted agents: lessons learned building a production multi-agent service]]></title>
            <link>https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/</link>
            <guid>https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/</guid>
            <pubDate>Fri, 24 Jul 2026 01:42:15 GMT</pubDate>
            <description><![CDATA[Lessons and best practices for hosting production multi-agent services with Microsoft Foundry hosted agents—compute, protocols, sizing, and deployment tips.]]></description>
            <content:encoded><![CDATA[<p>Agent workloads can sometimes be an awkward fit for the compute we would usually reach for. They can be bursty (a synthesis run spikes, then nothing for an hour), some of them are cadence-driven (a follow-up agent that wakes every 30 days), and a few run long enough that a request-response timeout is meaningless. Put that on always-on container replicas and you pay for a lot of idle.</p>
<p><a href="https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agents?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Microsoft Foundry Hosted Agents</a> went generally available in July 2026, and it is built for that shape.</p>
<p>I built a service (product) that takes standard methodologies and frameworks, then supports them with agents (without giving away too much, I will keep this general). Six agents ran on Microsoft Foundry Hosted Agents, and one ran in a sidecar container.</p>
<p>This post is about the hosting decisions, protocol choices, and what I learnt while building that service, where the agents were built with Microsoft Agent Framework in Python.</p>
<p>It also builds on a few related posts: <a href="https://luke.geek.nz/azure/hosted-agents-container-apps-job/" target="_blank" rel="noopener noreferrer" class="">Get Ahead with Self-Hosted Agents and Container Apps Jobs</a>, <a href="https://luke.geek.nz/azure/azure-developer-cli" target="_blank" rel="noopener noreferrer" class="">Getting Started with Azure Developer CLI (azd)</a>, and <a href="https://luke.geek.nz/azure/azure-sre-agent-aks-drasi/" target="_blank" rel="noopener noreferrer" class="">Running Azure SRE Agent for AKS and Drasi Operations</a>.</p>
<p>In short: this is a production story about choosing the right compute and protocol for each agent shape, not a generic tour of features.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="tldr">TL;DR<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#tldr" class="hash-link" aria-label="Direct link to TL;DR" title="Direct link to TL;DR" translate="no">​</a></h2>
<ul>
<li class="">Hosted Agents are excellent for bursty or scheduled workloads.</li>
<li class="">Use Responses only when you genuinely need conversation history.</li>
<li class="">Most task-oriented agents are better suited to Invocations.</li>
<li class="">Size compute per agent rather than per application.</li>
<li class="">Agents designed around in-process callbacks will not migrate cleanly.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-microsoft-foundry-hosted-agents-give-you">What Microsoft Foundry hosted agents give you<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#what-microsoft-foundry-hosted-agents-give-you" class="hash-link" aria-label="Direct link to What Microsoft Foundry hosted agents give you" title="Direct link to What Microsoft Foundry hosted agents give you" translate="no">​</a></h2>
<p>The model is a VM-isolated sandbox created per session, on demand. It runs for the life of the session, then tears down. Two numbers define the economics:</p>
<ul>
<li class=""><strong>15-minute idle timeout.</strong> Compute is deprovisioned after 15 minutes of inactivity, and there is no cost while an agent is not serving requests.</li>
<li class=""><strong>30-day maximum session lifetime.</strong> Long-running work is a first-class case, not something you fight the platform over.</li>
</ul>
<p>The part that makes the idle timeout usable is that session contents survive it. Files written under <code>$HOME</code> are preserved when compute is deprovisioned and restored when the session resumes, so an idle gap is not a cold start from nothing.</p>
<p>The identity story is the bit I liked the most for this solution - the platform creates a dedicated Microsoft Entra agent identity for each hosted agent at deploy time, as a service principal the running container uses to call models and tools. You do not wire managed identities by hand. It can reach model inferencing and session storage by default, and for anything external (ie your own storage account) you can assign RBAC to that agent's identity. Six agents means six identities, which shrinks the blast radius of any one of them considerably.</p>
<div class="theme-admonition theme-admonition-info admonition_xJq3 alert alert--info"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M7 2.3c3.14 0 5.7 2.56 5.7 5.7s-2.56 5.7-5.7 5.7A5.71 5.71 0 0 1 1.3 8c0-3.14 2.56-5.7 5.7-5.7zM7 1C3.14 1 0 4.14 0 8s3.14 7 7 7 7-3.14 7-7-3.14-7-7-7zm1 3H6v5h2V4zm0 6H6v2h2v-2z"></path></svg></span>Why this matters</div><div class="admonitionContent_BuS1"><p>Six hosted agents means six Microsoft Entra identities.</p><p>If one agent is compromised, it cannot automatically access permissions granted to another.</p></div></div>
<p>Telemetry is on by default too. The platform injects an Application Insights connection string and OpenTelemetry tracing without custom wiring, alongside a set of reserved variables:</p>
<table><thead><tr><th>Variable</th><th>Purpose</th></tr></thead><tbody><tr><td><code>FOUNDRY_PROJECT_ENDPOINT</code></td><td>Foundry project endpoint URL</td></tr><tr><td><code>FOUNDRY_PROJECT_ARM_ID</code></td><td>Foundry project ARM resource ID</td></tr><tr><td><code>FOUNDRY_AGENT_NAME</code></td><td>Name of the running agent</td></tr><tr><td><code>FOUNDRY_AGENT_VERSION</code></td><td>Version of the running agent</td></tr><tr><td><code>FOUNDRY_AGENT_SESSION_ID</code></td><td>Session ID for the current request</td></tr><tr><td><code>APPLICATIONINSIGHTS_CONNECTION_STRING</code></td><td>Application Insights connection string</td></tr></tbody></table>
<p>The <code>FOUNDRY_</code> prefix is reserved, and you should not redeclare any of these in your own configuration.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="pick-your-protocol-and-pick-it-on-interaction-shape">Pick your protocol, and pick it on interaction shape<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#pick-your-protocol-and-pick-it-on-interaction-shape" class="hash-link" aria-label="Direct link to Pick your protocol, and pick it on interaction shape" title="Direct link to Pick your protocol, and pick it on interaction shape" translate="no">​</a></h2>
<p>Containers can talk to the Foundry gateway through a protocol library, and this is where my original plan was wrong.</p>
<table><thead><tr><th>Protocol</th><th>Python library</th><th>Endpoint</th><th>Best for</th></tr></thead><tbody><tr><td>Responses</td><td><code>azure-ai-agentserver-responses</code></td><td><code>/responses</code></td><td>Conversational, streaming, multi-turn with platform-managed history</td></tr><tr><td>Invocations</td><td><code>azure-ai-agentserver-invocations</code></td><td><code>/invocations</code></td><td>Webhook receivers, non-conversational processing, async workflows</td></tr><tr><td>Invocations (WebSocket)</td><td><code>azure-ai-agentserver-invocations</code></td><td><code>/invocations_ws</code></td><td>Bidirectional streaming, real-time voice, interactive media</td></tr></tbody></table>
<p>Both protocol libraries are in preview (the hosting platform itself is GA). They work, but the API surface can still change between beta releases.</p>
<p>I initially mapped all six Agents to Responses, because "agent" and "chat" are easy to conflate. That was wrong for half of them. <code>synthesis</code>, <code>simulation</code>, and <code>board-export</code> specific Agents were more controlled and structured: typed JSON in, typed JSON out, it had no conversation and no history to manage. So I moved to Invocations, along with a cadence-driven <code>follow-up</code>. Only a <code>conversational-qa</code> genuinely needed the Responses protocol.</p>
<blockquote>
<p>A useful heuristic: choose on interaction shape, not an "agent". If there is no multi-turn history worth the platform managing, Invocations is simpler, a single container can expose more than one protocol by declaring them in the <code>protocols</code> field, so this is not a one-way door.</p>
</blockquote>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>Architecture at a glance</div><div class="admonitionContent_BuS1"></div></div>
<div class="theme-admonition theme-admonition-tip admonition_xJq3 alert alert--success"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 12 16"><path fill-rule="evenodd" d="M6.5 0C3.48 0 1 2.19 1 5c0 .92.55 2.25 1 3 1.34 2.25 1.78 2.78 2 4v1h5v-1c.22-1.22.66-1.75 2-4 .45-.75 1-2.08 1-3 0-2.81-2.48-5-5.5-5zm3.64 7.48c-.25.44-.47.8-.67 1.11-.86 1.41-1.25 2.06-1.45 3.23-.02.05-.02.11-.02.17H5c0-.06 0-.13-.02-.17-.2-1.17-.59-1.83-1.45-3.23-.2-.31-.42-.67-.67-1.11C2.44 6.78 2 5.65 2 5c0-2.2 2.02-4 4.5-4 1.22 0 2.36.42 3.22 1.19C10.55 2.94 11 3.94 11 5c0 .66-.44 1.78-.86 2.48zM4 14h5c-.23 1.14-1.3 2-2.5 2s-2.27-.86-2.5-2z"></path></svg></span>tip</div><div class="admonitionContent_BuS1"><p>Containers serve on port <strong>8088</strong> locally, and the protocol libraries expose a <code>/readiness</code> endpoint for platform health checks automatically, so you do not implement it yourself. That means you can <code>POST http://localhost:8088/responses</code> and exercise the exact endpoint shape you get in production before you deploy anything.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="sizing-is-per-agent">Sizing is per agent<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#sizing-is-per-agent" class="hash-link" aria-label="Direct link to Sizing is per agent" title="Direct link to Sizing is per agent" translate="no">​</a></h2>
<p>Compute is allocated per agent as a CPU and memory pair, so sizing is an agent-by-agent decision rather than a platform-wide one. This is how mine looked:</p>
<table><thead><tr><th>Agent</th><th>Allocation</th><th>Why</th></tr></thead><tbody><tr><td><code>conversational-qa</code></td><td>0.5 vCPU / 1 GiB</td><td>Retrieval-augmented Q&amp;A, high idle time between user bursts</td></tr><tr><td><code>follow-up</code></td><td>0.5 vCPU / 1 GiB</td><td>Lightweight cadence agent on a 30/60/90 schedule</td></tr><tr><td><code>synthesis</code></td><td>1 vCPU / 2 GiB</td><td>Multi-step reasoning with tool orchestration and a human handoff</td></tr><tr><td><code>board-export</code></td><td>1 vCPU / 2 GiB</td><td>Orchestrates the export; heavy rendering stays in a separate service</td></tr><tr><td><code>simulation</code></td><td>2 vCPU / 4 GiB</td><td>Deterministic rules engine, heaviest per invocation</td></tr></tbody></table>
<blockquote>
<p>Because compute scales to zero, the sizing decision is about the shape of a single invocation, not about what you are willing to pay to leave running!</p>
</blockquote>
<p>Note the <code>board-export</code> line. I deliberately kept PDF and PowerPoint rendering (which is what this particular agent was helping with) out of the agent container and in a separate service, so the agent stays inside a modest allocation and the rendering can scale on its own terms.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="the-one-agent-i-could-not-host">The one agent I could not host<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#the-one-agent-i-could-not-host" class="hash-link" aria-label="Direct link to The one agent I could not host" title="Direct link to The one agent I could not host" translate="no">​</a></h2>
<p>There was one agent I wasn't able to host in Foundry Hosted Agents: a pre-mortem agent.</p>
<p>It used a <code>run()</code> method that takes a <code>cycle_runner: Callable[[int], Mapping]</code>, a Python callback the loop invokes on each cycle. That works fine in-process. It cannot work across the Hosted Agent RPC boundary, because you cannot serialise a Python callable and send it over the wire. The agent had to be reworked into an event-driven model, with cycle steps published through a queue or a change-detection reaction, before it can be hosted at all.</p>
<div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><div class="admonitionHeading_Gvgb"><span class="admonitionIcon_Rf37"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>Migration blocker pattern</div><div class="admonitionContent_BuS1"><p>If your agent entry point accepts callables, open handles, shared in-memory objects, or live connections, it is implicitly assuming in-process execution.</p><p>Across an RPC boundary, those assumptions break.</p></div></div>
<p>The general form of this: an agent that accepts a function as a parameter is quietly assuming it shares a process with its caller. That assumption is invisible in an architecture diagram, and it survives every unit test, right up until the agent becomes a container behind an RPC boundary. If you are planning a move to hosted compute, make sure to check your agent entry-points first and look for anything that is a callable, an open handle, a live connection, or a shared in-memory object. Those are your migration blockers, and they are much cheaper to find in an afternoon of reading than in a sprint of rework!</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="deploying-with-azd">Deploying with azd<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#deploying-with-azd" class="hash-link" aria-label="Direct link to Deploying with azd" title="Direct link to Deploying with azd" translate="no">​</a></h2>
<p>I use <a href="https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd?tabs=winget-windows%2Cbrew-mac%2Cscript-linux&amp;pivots=os-windows&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Developer CLI</a> heavily in my projects - especially personal projects like this one. If you want the wider AZD view, I covered that in <a href="https://luke.geek.nz/azure/azure-developer-cli" target="_blank" rel="noopener noreferrer" class="">Getting Started with Azure Developer CLI (azd)</a>. It makes redeployment easy, helps reduce Azure spend by deploying only when needed, and is a good pressure test for what a workload actually needs.</p>
<p>A simple:</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">azd deploy</span><br></div></code></pre></div></div>
<p>This can provision and deploy an entire workload. In my service, it also built container images remotely in Azure Container Registry (so I did not need local Docker), pushed them, created a hosted agent version, created the dedicated Entra agent identity, and assigned the RBAC that identity needed. Each deploy creates a new version, previous versions are preserved, and the latest is active by default. <code>azd ai agent show</code> prints the name, version, protocols, container resources, and environment variables.</p>
<p>Agents were declared as a service in <code>azure.yaml</code>:</p>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">services</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">my-agent</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">host</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> azure.ai.agent</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">env</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">MODEL_DEPLOYMENT_NAME</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> gpt</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">5</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">mini</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">GITHUB_TOKEN</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> $</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain">connections.agent</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain">secrets.credentials.github_token</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>That second variable is worth calling out. Rather than baking a secret into the image or the YAML, you can reference a Foundry project connection with a <code>${{connections.&lt;name&gt;.&lt;path&gt;}}</code> placeholder, and the platform resolves it at sandbox start. A GET on the agent version returns the literal <code>${{...}}</code> text, so the resolved secret never comes back through the management API.</p>
<p>A few things worth mentioning that will cost you an afternoon if you miss them:</p>
<ul>
<li class="">Images must be <strong>x86_64 (linux/amd64)</strong>. On Apple Silicon, build with <code>docker build --platform linux/amd64 .</code> or you will produce an image the platform cannot run.</li>
<li class="">You need the <strong>Foundry Project Manager</strong> role at project scope to deploy (recently renamed from Azure AI Project Manager, so both names are still floating about).</li>
<li class="">Putting the container registry behind a private endpoint only works for Foundry projects created after <strong>25 June 2026</strong>. Older projects need the registry reachable publicly so the platform can pull.</li>
<li class="">For the SDK path, <code>azure-ai-projects</code> <strong>2.3.0 or later</strong>.</li>
</ul>
<p>For me, I even used <a href="https://techcommunity.microsoft.com/blog/linuxandopensourceblog/announcing-azure-linux-4-0-purpose-built-for-azure-now-in-public-preview/4524267?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Azure Linux 4 Beta</a>, mostly for one reason: <code>because I could</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="final-thoughts">Final thoughts<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#final-thoughts" class="hash-link" aria-label="Direct link to Final thoughts" title="Direct link to Final thoughts" translate="no">​</a></h2>
<p>For bursty and cadence-driven agents, per-session scale-to-zero and a per-agent identity remove a real pile of undifferentiated work: replica counts, warm pools, and identity wiring you would otherwise need to own.</p>
<p>If you are evaluating Hosted Agents today, my recommendations are:</p>
<ul>
<li class="">Start with one task-oriented agent and validate end-to-end behavior in production-like traffic.</li>
<li class="">Default to Invocations unless conversation history clearly adds value.</li>
<li class="">Keep heavy rendering and binary processing outside the hosted container.</li>
<li class="">Design agents around message contracts and events, not callbacks.</li>
<li class="">Treat each agent as an independently deployable workload with its own identity and sizing.</li>
</ul>
<p>Hopefully this helps if you're weighing up Hosted Agents for real workloads.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/foundryhostedagentmultiagentlesson/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agents?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Hosted agents in Foundry Agent Service</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/foundry/agents/how-to/deploy-hosted-agent?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Deploy a hosted agent</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Manage hosted agent sessions</a></li>
<li class=""><a href="https://devblogs.microsoft.com/foundry/introducing-the-new-hosted-agents-in-foundry-agent-service-secure-scalable-compute-built-for-agents/?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Introducing the new hosted agents in Foundry Agent Service</a></li>
<li class=""><a href="https://azure.microsoft.com/updates?id=563546" target="_blank" rel="noopener noreferrer" class="">Azure Update 563546: Agent Harness GA</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
        <item>
            <title><![CDATA[AKS managed Gateway API blocks the ALB controller]]></title>
            <link>https://luke.geek.nz/azure/aks-mgtgw-lab/</link>
            <guid>https://luke.geek.nz/azure/aks-mgtgw-lab/</guid>
            <pubDate>Wed, 15 Jul 2026 00:15:28 GMT</pubDate>
            <description><![CDATA[AKS managed Gateway API blocked my ALB controller rollout. Use managed add-on pinning for lifecycle safety, or use Helm when you need independent Gateway API + ALB controller versions.]]></description>
            <content:encoded><![CDATA[<p>I was setting up Application Gateway for Containers (AGC) as the ingress layer for a project cluster. The AKS add-on makes it look straightforward in Bicep - enable it, deploy your Gateway and HTTPRoute manifests, done. What I ran into instead was a version deadlock between two managed add-ons that left the Gateway stuck at <code>PROGRAMMED: Unknown</code> for twelve hours and Front Door returning 504 the whole time.</p>
<p>This is what happened, why, and how I got out of it.</p>
<blockquote>
<p><strong>Update (2026-07-23):</strong> Microsoft has clarified the add-on behavior and support matrix. If you use the AKS add-on path, ALB controller versions are pinned by cluster version + Managed Gateway API bundle support. See the official matrix: <a href="https://learn.microsoft.com/azure/aks/managed-gateway-api?WT.mc_id=AZ-MVP-5004796#supported-kubernetes-versions-for-gateway-api-bundle-versions" target="_blank" rel="noopener noreferrer" class="">Supported Kubernetes versions for Gateway API bundle versions</a>.</p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-i-saw">What I saw<a href="https://luke.geek.nz/azure/aks-mgtgw-lab/#what-i-saw" class="hash-link" aria-label="Direct link to What I saw" title="Direct link to What I saw" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="AGC Gateway showing PROGRAMMED=Unknown and Front Door returning 504" src="https://luke.geek.nz/assets/images/agc-gateway-status-4ea9a6229b3a76f0e3fa63892334301c.gif" width="1125" height="552" class="img_ev3q"></p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl get gateway </span><span class="token operator">-</span><span class="token plain">n wc2026</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># NAME             CLASS                ADDRESS   PROGRAMMED   AGE</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># wc2026-gateway   azure-alb-external             Unknown      12h</span><br></div></code></pre></div></div>
<p>Front Door endpoint returning 504:</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">curl </span><span class="token operator">-</span><span class="token plain">s </span><span class="token operator">-</span><span class="token plain">o </span><span class="token operator">/</span><span class="token plain">dev/null </span><span class="token operator">-</span><span class="token plain">w </span><span class="token string" style="color:rgb(255, 121, 198)">"%{http_code}"</span><span class="token plain"> https:</span><span class="token operator">/</span><span class="token operator">/</span><span class="token plain">wc2026-api-etckbtg6fxesbzgs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">z02</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">azurefd</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">net/health/ready</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># 504</span><br></div></code></pre></div></div>
<p>The gateway existed. The AGC traffic controller was provisioned, but the ALB controller had never reconciled the Gateway resource - it was sitting there doing nothing.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="incident-timeline-original-run">Incident timeline (original run)<a href="https://luke.geek.nz/azure/aks-mgtgw-lab/#incident-timeline-original-run" class="hash-link" aria-label="Direct link to Incident timeline (original run)" title="Direct link to Incident timeline (original run)" translate="no">​</a></h2>
<p>The sequence below is the exact chronology from my original incident run.</p>
<p>The deployment sequence looked fine on paper:</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">1. Deploy AGC traffic controller via Bicep -- succeeds</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">2. Deploy Gateway API + HTTPRoute manifests -- applied</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">3. Enable ALB controller add-on: az aks update --enable-application-load-balancer</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">4. Error: Preview feature ApplicationLoadBalancerPreview not registered</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">5. Register feature, propagate provider -- add-on installs</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">6. ALB controller pods crash-loop:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   "no matches for kind 'ReferenceGrant' in version 'gateway.networking.k8s.io/v1'"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">7. Root cause in my install path: ALB controller (v1.11.1) expected newer Gateway API CRDs</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  than what my managed Gateway API install exposed at the time</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">8. AKS managed Gateway API add-on bundle on my cluster lagged the controller expectations</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">9. Try to upgrade CRDs manually -- denied:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   "managed Gateway API disallows modifying Gateway CRDs"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">10. Try to disable managed Gateway API to install CRDs manually -- denied:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    "Application Load Balancer add-on requires managed Gateway API"</span><br></div></code></pre></div></div>
<p>That last step is where it locks up completely. The ALB controller needs newer CRDs than the managed Gateway API provides. The managed Gateway API blocks you from upgrading the CRDs yourself. And you cannot remove the managed Gateway API because the ALB controller add-on depends on it.</p>
<p>Every door was closed.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-i-did">What I did<a href="https://luke.geek.nz/azure/aks-mgtgw-lab/#what-i-did" class="hash-link" aria-label="Direct link to What I did" title="Direct link to What I did" translate="no">​</a></h2>
<p>The only exit for this cluster was to pull both managed add-ons out and own the installation via Helm:</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)"># Disable both managed add-ons</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">az aks update </span><span class="token operator">--</span><span class="token plain">resource-</span><span class="token function" style="color:rgb(80, 250, 123)">group</span><span class="token plain"> rg-mvp </span><span class="token operator">--</span><span class="token plain">name wc2026-fanintel-aks \</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token operator">--</span><span class="token function" style="color:rgb(80, 250, 123)">disable-application</span><span class="token operator">-</span><span class="token plain">load-balancer</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">az aks update </span><span class="token operator">--</span><span class="token plain">resource-</span><span class="token function" style="color:rgb(80, 250, 123)">group</span><span class="token plain"> rg-mvp </span><span class="token operator">--</span><span class="token plain">name wc2026-fanintel-aks \</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token operator">--</span><span class="token function" style="color:rgb(80, 250, 123)">disable-gateway</span><span class="token operator">-</span><span class="token plain">api</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># Install Gateway API CRDs v1.5.1 manually</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl apply </span><span class="token operator">-</span><span class="token plain">f https:</span><span class="token operator">/</span><span class="token operator">/</span><span class="token plain">github</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">com/kubernetes-sigs/gateway-api/releases/download/v1</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">5</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">1/standard-install</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">yaml</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># Install ALB controller via Helm</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">helm install alb-controller oci:</span><span class="token operator">/</span><span class="token operator">/</span><span class="token plain">mcr</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">microsoft</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">com/application-lb/charts/alb-controller \</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token operator">--</span><span class="token plain">version 1</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">11</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">3 \</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token operator">--</span><span class="token function" style="color:rgb(80, 250, 123)">set</span><span class="token plain"> cluster</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">name=wc2026-fanintel-aks \</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token operator">--</span><span class="token function" style="color:rgb(80, 250, 123)">set</span><span class="token plain"> cluster</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">resourceGroup=rg-mvp</span><br></div></code></pre></div></div>
<p>With the right CRD version in place, the controller installed cleanly and started reconciling the Gateway resource straight away.</p>
<p>This Helm path is supported (not a hack) when you want ALB controller + Gateway API versions independent of cluster-version-based add-on pinning.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="after-the-fix">After the fix<a href="https://luke.geek.nz/azure/aks-mgtgw-lab/#after-the-fix" class="hash-link" aria-label="Direct link to After the fix" title="Direct link to After the fix" translate="no">​</a></h2>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl get pods </span><span class="token operator">-</span><span class="token plain">n azure-alb-system</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># alb-controller-*   Running</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl get gateway </span><span class="token operator">-</span><span class="token plain">n wc2026</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># wc2026-gateway   azure-alb-external   &lt;ip&gt;   Programmed   True</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">curl </span><span class="token operator">-</span><span class="token plain">s </span><span class="token operator">-</span><span class="token plain">o </span><span class="token operator">/</span><span class="token plain">dev/null </span><span class="token operator">-</span><span class="token plain">w </span><span class="token string" style="color:rgb(255, 121, 198)">"%{http_code}"</span><span class="token plain"> https:</span><span class="token operator">/</span><span class="token operator">/</span><span class="token plain">wc2026-api-etckbtg6fxesbzgs</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">z02</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">azurefd</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">net/health/ready</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># 200</span><br></div></code></pre></div></div>
<p>Gateway programmed, Front Door responding. The tradeoff is that you are now managing the ALB controller version yourself - no automatic add-on upgrades, and you lose the managed identity wiring the add-on configures for you. Worth keeping in mind when you plan upgrades.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="post-update-clarification-2026-07-23">Post-update clarification (2026-07-23)<a href="https://luke.geek.nz/azure/aks-mgtgw-lab/#post-update-clarification-2026-07-23" class="hash-link" aria-label="Direct link to Post-update clarification (2026-07-23)" title="Direct link to Post-update clarification (2026-07-23)" translate="no">​</a></h2>
<p>Microsoft has now clarified that when you use the AKS add-on path, you should not have to manually manage Gateway API/ALB controller compatibility matrices. Add-on versions are pinned by cluster Kubernetes version and Managed Gateway API bundle support.</p>
<p>Correction from Microsoft PM: ALB Controller <code>v1.11.x</code> targets Gateway API <code>v1.5.1</code>, and <code>v1.5.1</code> CRDs are recommended with <code>v1.11+</code> for full functionality. Latest mappings are published in the ALB controller release notes.</p>
<p>Practical guidance:</p>
<ul>
<li class="">If you want managed lifecycle and automatic compatibility pinning, use AKS add-ons and follow the documented support matrix.</li>
<li class="">If you want the latest Gateway API + ALB controller independent of cluster version, Helm is a supported self-managed path.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="what-i-learned">What I learned<a href="https://luke.geek.nz/azure/aks-mgtgw-lab/#what-i-learned" class="hash-link" aria-label="Direct link to What I learned" title="Direct link to What I learned" translate="no">​</a></h2>
<p>The core failure mode in my incident was managed-component version coupling during rollout timing. The key lesson for me is to choose the operating model early:</p>
<ul>
<li class="">Managed add-ons for lifecycle simplicity and built-in compatibility pinning.</li>
<li class="">Helm for independent version control, with explicit ownership of upgrades and identity wiring.</li>
</ul>
<p>Hopefully this saves you a few hours of digging through crash loops and error messages.</p>
<h2 class="anchor anchorTargetStickyNavbar_Vzrq" id="references">References<a href="https://luke.geek.nz/azure/aks-mgtgw-lab/#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class=""><a href="https://learn.microsoft.com/azure/application-gateway/for-containers/overview?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Application Gateway for Containers documentation</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/application-gateway/for-containers/quickstart-deploy-application-gateway-for-containers-alb-controller-helm?tabs=azure-cli%2Cinstall-helm-windows&amp;WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">ALB controller Helm installation</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/application-gateway/for-containers/alb-controller-release-notes?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">ALB controller release notes</a></li>
<li class=""><a href="https://learn.microsoft.com/azure/aks/managed-gateway-api#supported-kubernetes-versions-for-gateway-api-bundle-versions?WT.mc_id=AZ-MVP-5004796" target="_blank" rel="noopener noreferrer" class="">Managed Gateway API support matrix</a></li>
<li class=""><a href="https://gateway-api.sigs.k8s.io/" target="_blank" rel="noopener noreferrer" class="">Gateway API CRDs</a></li>
</ul>]]></content:encoded>
            <category>Azure</category>
        </item>
    </channel>
</rss>