<?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>Elastic Observability Labs - Articles by Valeriy Khakhutskyy</title>
        <link>https://www.elastic.co/observability-labs</link>
        <description>Trusted security news &amp; research from the team at Elastic.</description>
        <lastBuildDate>Wed, 29 Jul 2026 16:24:22 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Elastic Observability Labs - Articles by Valeriy Khakhutskyy</title>
            <url>https://www.elastic.co/observability-labs/assets/observability-labs-thumbnail.png</url>
            <link>https://www.elastic.co/observability-labs</link>
        </image>
        <copyright>© 2026. Elasticsearch B.V. All Rights Reserved</copyright>
        <item>
            <title><![CDATA[Elastic ML predicts when your disk will fill up: How to make it alert you]]></title>
            <link>https://www.elastic.co/observability-labs/blog/disk-capacity-forecasting-kibana-workflows</link>
            <guid isPermaLink="false">disk-capacity-forecasting-kibana-workflows</guid>
            <pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Use a single Kibana Workflows YAML to run daily ML forecasts on disk usage and get Slack alerts listing which hosts will hit capacity and when.]]></description>
            <content:encoded><![CDATA[<p>Elastic ML can already forecast disk usage 14 days out, with a confidence band on the upper and lower bounds. <a href="https://www.elastic.co/docs/explore-analyze/workflows">Kibana Workflows</a> in Elastic 9.4 puts that forecast into a daily routine. One YAML file checks the confidence band against a 90% threshold and sends a Slack alert listing every host and mount point about to breach it before it happens.</p>
<p>Here's the full picture of what we're building:</p>
<p><img src="https://www.elastic.co/observability-labs/assets/images/disk-capacity-forecasting-kibana-workflows/image4.png" alt="Kibana Workflows steps for disk capacity alerts: forecast, breach search, Slack notification" /></p>
<h2>What is Elastic ML forecasting?</h2>
<p>Elastic's <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/anomaly-detection">anomaly detection</a> jobs model the normal behavior of a time series. Once the model has learned the pattern, the <a href="https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-ml-forecast">forecast API</a> can project it forward with a confidence interval. A <code>POST /_ml/anomaly_detectors/{job_id}/_forecast</code> call runs the projection for a specified duration and writes the results into the job's results index as <code>model_forecast</code> documents.</p>
<p>Each document carries three values: <code>forecast_prediction</code> (the point estimate), <code>forecast_lower</code>, and <code>forecast_upper</code> (the bounds of a 95% confidence interval).</p>
<p>For capacity alerting, we want <code>forecast_upper</code>, not <code>forecast_prediction</code>. Think of it like a weather forecast: we'd rather carry an umbrella on a day that turns out sunny than get caught in the rain because we trusted the optimistic estimate.</p>
<p><img src="https://www.elastic.co/observability-labs/assets/images/disk-capacity-forecasting-kibana-workflows/image2.png" alt="Disk capacity forecast in Kibana's single metric viewer with a 14-day confidence band for host-a" /></p>
<h2>Setting up the ML job</h2>
<p>The ML job is a one-time setup. It can live for the lifetime of the cluster. It needs at least a few days of data before forecasts become meaningful; for disk capacity trends, plan on a week or two.</p>
<p>We could simply use the built-in job <code>max_disk_utilization_ecs</code> available in the supplied anomaly detection configurations in Kibana.</p>
<p><img src="https://www.elastic.co/observability-labs/assets/images/disk-capacity-forecasting-kibana-workflows/image1.png" alt="Disk capacity anomaly detection job config in Kibana: the built-in max_disk_utilization_ecs job" /></p>
<p>For the purposes of this demonstration, a slightly more advanced job was created that additionally supplies the mounting point information. This way, the alert will contain not only the information about which host is running out of storage, but also which mounting point is affected.</p>
<pre><code class="language-json">{
  &quot;job_id&quot;: &quot;disk-usage-forecast&quot;,
  &quot;analysis_config&quot;: {
    &quot;bucket_span&quot;: &quot;1h&quot;,
    &quot;detectors&quot;: [
      {
        &quot;function&quot;: &quot;max&quot;,
        &quot;field_name&quot;: &quot;system.filesystem.used.pct&quot;,
        &quot;partition_field_name&quot;: &quot;host.name&quot;,
        &quot;by_field_name&quot;: &quot;system.filesystem.mount_point&quot;
      }
    ],
    &quot;influencers&quot;: [&quot;host.name&quot;, &quot;system.filesystem.mount_point&quot;]
  },
  &quot;data_description&quot;: {
    &quot;time_field&quot;: &quot;@timestamp&quot;
  },
  &quot;datafeed_config&quot;: {
    &quot;datafeed_id&quot;: &quot;datafeed-disk-usage-forecast&quot;,
    &quot;indices&quot;: [&quot;metrics-system.filesystem-*&quot;],
    &quot;query&quot;: {
      &quot;bool&quot;: {
        &quot;filter&quot;: [
          { &quot;exists&quot;: { &quot;field&quot;: &quot;system.filesystem.used.pct&quot; } },
          { &quot;exists&quot;: { &quot;field&quot;: &quot;host.name&quot; } },
          { &quot;exists&quot;: { &quot;field&quot;: &quot;system.filesystem.mount_point&quot; } }
        ]
      }
    }
  },
  &quot;model_plot_config&quot;: { &quot;enabled&quot;: false },
  &quot;results_index_name&quot;: &quot;disk-usage-forecast&quot;,
  &quot;results_retention_days&quot;: 28
}
</code></pre>
<p>A few choices worth calling out:</p>
<ol>
<li>
<p><strong>Double-split by host and mount.</strong> <code>partition_field_name: host.name</code> and <code>by_field_name: system.filesystem.mount_point</code> gives each host-mount combination its own independent model. A host with five partitions gets five independent trend lines. Field implementations have targeted this pattern at ~50,000 splits (roughly 10,000 hosts x 5 mount points). At that scale, bucket-span tuning and job splitting become necessary (see the scaling section below).</p>
</li>
<li>
<p><strong><code>model_plot_config.enabled: false</code>.</strong> The Kibana UI warns at around 100 series for the model plot; that warning doesn't apply once the model plot is off. We're alerting on the forecast results index, not on model plot data, so we don't need it.</p>
</li>
<li>
<p><strong>Bucket span.</strong> The config uses <code>1h</code>, which gives granular breach timestamps in the Slack alert. For production, many teams prefer <code>6h</code> or <code>1d</code> for disk capacity monitoring as it reduces the results index volume and smooths short-term noise.</p>
</li>
<li>
<p><strong>Adjust the index pattern</strong> (<code>metrics-system.filesystem-*</code>) to match your <a href="https://www.elastic.co/docs/reference/fleet">Elastic Agent</a> or <a href="https://www.elastic.co/docs/reference/beats/metricbeat">Metricbeat</a> setup.</p>
</li>
</ol>
<p>After creating the job, let's open it and start the datafeed:</p>
<pre><code>POST /_ml/anomaly_detectors/disk-usage-forecast/_open
PUT /_ml/datafeeds/datafeed-disk-usage-forecast/_start
</code></pre>
<p>Then wait for the model to process enough historical data before running the workflow.</p>
<h2>Why forecast rather than alert on current disk usage?</h2>
<p>A threshold rule fires when a disk hits 90%. At that point, we're in incident-response mode, scrambling to free up space before services fail. Forecasting gives us lead time.</p>
<p>Going back to the weather analogy: a threshold alert is a rain sensor that triggers when we're already soaked. An ML forecast is the weather forecast that tells us to bring an umbrella tomorrow. With a 14-day forecast running daily, we have time to act: add storage, move data, and clean up logs before anything breaks.</p>
<p>One caveat to keep in mind: ML forecasting assumes trend continuation. If a host's usage changes suddenly because a new application deploys or a log rotation kicks in, the forecast will lag behind the new reality. Run the forecast daily; it reflects today's data. Skip the regeneration, and you're alerting on a trend that may no longer exist.</p>
<h2>The Kibana Workflows YAML for disk capacity alerts</h2>
<p>All three active steps live in a single YAML file. The <code>consts</code> section at the top is where we set the job ID, threshold, and Slack connector. The rest of the logic stays untouched across environments.</p>
<pre><code class="language-yaml">name: disk-capacity-forecast-alert
description: &gt;
  Daily ML forecast on disk-usage-forecast job; alert Slack when the upper
  confidence bound crosses 90% at a future timestamp.
enabled: true
tags:
  - machine-learning
  - capacity-planning
  - observability

consts:
  job_id: disk-usage-forecast
  results_index: .ml-anomalies-disk-usage-forecast
  forecast_duration: 14d
  forecast_expires_in: 24h
  forecast_max_model_memory: 200mb     # capped at min(500mb, 40% of job model_memory_limit)
  breach_threshold: 0.9
  max_partitions_in_alert: 20          # cap the list; total count is reported separately
  slack_connector_id: REPLACE_WITH_SLACK_CONNECTOR_ID
  slack_channel: REPLACE_WITH_SLACK_CHANNEL_ID

triggers:
  - type: scheduled
    with:
      rrule:
        freq: DAILY
        interval: 1
        tzid: UTC
        byhour: [6]
        byminute: [0]

steps:
  # Step 1: generate a fresh 14-day forecast
  - name: run_forecast
    type: elasticsearch.request
    with:
      method: POST
      path: &quot;/_ml/anomaly_detectors/{{ consts.job_id }}/_forecast&quot;
      body:
        duration: &quot;{{ consts.forecast_duration }}&quot;
        expires_in: &quot;{{ consts.forecast_expires_in }}&quot;
        max_model_memory: &quot;{{ consts.forecast_max_model_memory }}&quot;

  # Step 2: poll until the forecast finishes and its docs are searchable
  # (a fixed sleep races at high cardinality; this waits exactly as long as needed)
  - name: wait_for_forecast
    type: while
    condition: &quot;steps.check_forecast.output.hits.total.value &lt; 1&quot;
    max-iterations:
      limit: 60
      on-limit: fail
    steps:
      - name: backoff
        type: wait
        with:
          duration: 2s
      - name: check_forecast
        type: elasticsearch.request
        with:
          method: POST
          path: &quot;/{{ consts.results_index }}/_search&quot;
          body:
            size: 0
            query:
              bool:
                filter:
                  - term: { job_id: &quot;{{ consts.job_id }}&quot; }
                  - term: { result_type: model_forecast_request_stats }
                  - term: { forecast_id: &quot;{{ steps.run_forecast.output.forecast_id }}&quot; }
                  - term: { forecast_status: finished }

  # Step 3: find future buckets where the upper bound crosses the threshold
  - name: search_breaches
    type: elasticsearch.request
    with:
      method: POST
      path: &quot;/{{ consts.results_index }}/_search&quot;
      body:
        size: 0
        runtime_mappings:
          host_mount:
            type: keyword
            script:
              source: &quot;emit(doc['partition_field_value'].value + '|' + doc['by_field_value'].value)&quot;
        query:
          bool:
            filter:
              - term: { job_id: &quot;{{ consts.job_id }}&quot; }
              - term: { result_type: model_forecast }
              - term: { forecast_id: &quot;{{ steps.run_forecast.output.forecast_id }}&quot; }
              - range: { forecast_upper: { gte: &quot;{{ consts.breach_threshold }}&quot; } }
              - range: { timestamp: { gte: now } }   # future buckets only
        aggs:
          total_affected:                            # true distinct host×mount count
            cardinality: { field: host_mount }
          top_partitions:                            # bounded, most-urgent-first
            multi_terms:
              size: &quot;{{ consts.max_partitions_in_alert }}&quot;
              terms:
                - field: partition_field_value
                - field: by_field_value
              order: { earliest_breach: asc }
            aggs:
              earliest_breach: { min: { field: timestamp } }
              peak_forecast: { max: { field: forecast_upper } }

  # Step 4: alert only when breaches exist
  - name: notify_if_breach
    type: if
    condition: &quot;steps.search_breaches.output.hits.total.value &gt; 0&quot;
    steps:
      - name: slack_alert
        type: slack.postMessage
        connector-id: &quot;{{ consts.slack_connector_id }}&quot;
        with:
          channel: &quot;{{ consts.slack_channel }}&quot;
          text: |
            Disk capacity forecast alert (job: {{ consts.job_id }})
            Future breach buckets (forecast_upper &gt;= {{ consts.breach_threshold }}): {{ steps.search_breaches.output.hits.total.value }} across {{ steps.search_breaches.output.aggregations.total_affected.value }} partition(s)
            Horizon: {{ consts.forecast_duration }}

            Showing top {{ steps.search_breaches.output.aggregations.top_partitions.buckets | size }} partition(s), soonest breach first:
            {% for b in steps.search_breaches.output.aggregations.top_partitions.buckets %}- {{ b.key[0] }} {{ b.key[1] }} - peak {{ b.peak_forecast.value | times: 100 | round: 1 }}%, first crosses on {{ b.earliest_breach.value_as_string | date: &quot;%Y-%m-%d %H:%M UTC&quot; }} ({{ b.doc_count }} future bucket(s))
            {% endfor %}
    else:
      - name: no_breach_log
        type: console
        with:
          message: &quot;No disk forecast breaches above {{ consts.breach_threshold }} for {{ consts.job_id }}&quot;
</code></pre>
<p>A few things in this YAML are worth understanding before you run it:</p>
<ul>
<li><code>elasticsearch.request</code> is what actually returns the full response here. In 9.4.2 testing, elasticsearch.search strips out aggregations and only exposes <code>took</code>, <code>timed_out</code>, <code>_shards</code>, and <code>hits</code>. Since the Slack message is built by iterating over aggregation buckets, <code>method: POST</code> on elasticsearch.request is what gets you there instead.</li>
<li>The message template runs on Liquid. Workflows doesn't support Handlebars, so <code>{{#each}}</code> fails workflow validation if you're coming from a stack that uses it. The <code>{% for %}</code> loop and filters like <code>| times: 100 | round: 1</code> and <code>| date</code> all work fine in 9.4.2.</li>
<li>This polls until the forecast is actually done, instead of guessing with a fixed wait. <code>POST _forecast</code> returns a <code>forecast_id</code> almost immediately, but the result documents land asynchronously. A flat 5 second wait races against that at high cardinality. If the forecast isn't finished, the breach search comes back with 0 hits regardless of what the data says. The while step polls <code>model_forecast_request_stats</code> until <code>forecast_status: finished</code> instead, so it waits exactly as long as the forecast takes and no longer. <code>max-iterations</code> with <code>on-limit: fail</code> means a stuck forecast fails loudly rather than spinning into the 2000-iteration default.</li>
<li>The breach aggregation runs on multi_terms because composite can't order by a sub-aggregation metric. A composite agg would return partitions alphabetically, and a fixed size would quietly hide the worst offenders. multi_terms sorts by <code>earliest_breach</code> and caps the list at <code>max_partitions_in_alert</code>, while a separate <code>total_affected</code> cardinality aggregation reports the true distinct-partition count, so the message reads &quot;top 20 of 137&quot; instead of truncating without saying so. multi_terms bucket keys come back as arrays too, so they're indexed as <code>b.key[0]</code> (host) and <code>b.key[1]</code> (mount). The <code>timestamp &gt;= now</code> filter matters for the same reason: without it, the query would alert on historical buckets from the same forecast run, not just future ones.</li>
<li>Model memory and expiry get sized together. <code>max_model_memory</code> for the forecast defaults to 20 MB and is capped at <code>min(500mb, 40% of the job's model_memory_limit)</code>. A forecast that exceeds that budget spools to disk and runs slower, so raise both the job's <code>model_memory_limit</code> and <code>forecast_max_model_memory</code> together for large fleets. Each daily run also writes <code>horizon_buckets × partitions</code> documents to the results index, which is why <code>expires_in: 24h</code> is what keeps it from growing unbounded.</li>
</ul>
<h3>What the Slack message looks like</h3>
<p>When the workflow finds breaches, the alert looks like this:</p>
<pre><code>Disk capacity forecast alert (job: disk-usage-forecast)
Future breach buckets (forecast_upper &gt;= 0.9): 545 across 2 partition(s)
Horizon: 14d

Showing top 2 partition(s), soonest breach first:
- host-a / : peak 113.2%, first crosses on 2026-06-28 09:00 UTC (334 future bucket(s))
- host-b /data : peak 97.4%, first crosses on 2026-07-01 14:00 UTC (211 future bucket(s))
</code></pre>
<p>&quot;Peak 113.2%&quot; looks alarming, but it's mathematically correct: the upper confidence band can exceed 100% when the model's variance is wide. The useful number is &quot;first crosses on&quot;.  That's the runway we have to act. On a large fleet, the list is capped at <code>max_partitions_in_alert</code> (and ordered soonest-first), while &quot;across N partition(s)&quot; still reports the true total, so nothing is hidden.</p>
<h2>Operational considerations</h2>
<ul>
<li>
<p><strong>Cardinality.</strong> At tens of thousands of partitions (hosts × mount points), <code>model_plot_config.enabled: false</code> is not optional. The model plot data volume at that scale is significant and unnecessary for alerting. The breach search uses <code>multi_terms</code> capped at <code>max_partitions_in_alert</code> (default 20), ordered soonest-breach-first, and reports the true distinct-partition count via a <code>cardinality</code> aggregation, so the alert stays readable even when thousands of partitions breach.</p>
</li>
<li>
<p><strong>Wait timeout.</strong> The <code>while</code> step's <code>max-iterations</code> limit (60 iterations at 2s each) means forecasts can take up to 2 minutes to complete. At very high cardinality, this may not be enough; raise the <code>limit</code> if your forecasts legitimately run longer.</p>
</li>
<li>
<p><strong>Forecast quality.</strong> ML forecasting works best for metrics with consistent, learnable trends. Disk usage typically qualifies. Memory is noisier and can produce wide confidence bands, especially on hosts with variable workloads. If the Slack alert regularly shows &quot;peak 400%,&quot; the model may be underfitting on some partitions, and a tighter filter on <code>forecast_upper</code> max value can help reduce noise.</p>
</li>
</ul>
<h3>Scaling beyond ~1,000 partitions</h3>
<p>The formula the <a href="https://www.elastic.co/docs/explore-analyze/machine-learning/anomaly-detection/anomaly-detection-scale#reduce-the-cost-of-forecasting">scale guide</a> makes explicit:</p>
<p>Forecasting writes a new document to the result index for every forecasted element for every bucket.</p>
<p>So: <strong>result docs per run = horizon buckets × (hosts × mounts)</strong>. With the blog's <code>1h</code> bucket span and a <code>14d</code> horizon, that's 336 docs per partition per daily run:</p>
<table>
<thead>
<tr>
<th align="left">Fleet</th>
<th align="left">Partitions</th>
<th align="left">Docs/run</th>
<th align="left">Results index (24h TTL)</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left">Small PoC</td>
<td align="left">100</td>
<td align="left">34 k</td>
<td align="left">~17 MB</td>
</tr>
<tr>
<td align="left">~300 hosts × 10 mounts</td>
<td align="left">3,000</td>
<td align="left">1 M</td>
<td align="left">~470 MB</td>
</tr>
<tr>
<td align="left">~10k hosts × 3 mounts</td>
<td align="left">30,000</td>
<td align="left"><strong>10 M</strong></td>
<td align="left"><strong>~5 GB</strong></td>
</tr>
<tr>
<td align="left">~100k hosts × 3 mounts</td>
<td align="left">300,000</td>
<td align="left">100 M</td>
<td align="left">~47 GB</td>
</tr>
</tbody>
</table>
<p>At ~10 M docs per run, the indexing load and results-index size become significant, even with <code>expires_in: 24h</code>. The <code>search_breaches</code> query stays fast (it's <code>size: 0</code>, purely aggregation), but ingesting 10 M docs per day into <code>.ml-anomalies-*</code> alongside normal workload adds real pressure.</p>
<p><strong>The single biggest lever is bucket span.</strong> Disk capacity planning doesn't need hourly granularity over a 14-day horizon. &quot;First breach on Tuesday&quot; is actionable; &quot;first breach between 10:00 and 11:00 UTC&quot; usually isn't. Increasing bucket span reduces document volume multiplicatively:</p>
<table>
<thead>
<tr>
<th align="left">Bucket span</th>
<th align="left">Buckets (14d)</th>
<th align="left">Docs at 30k partitions</th>
<th align="left">Results index</th>
</tr>
</thead>
<tbody>
<tr>
<td align="left"><code>1h</code> (blog default)</td>
<td align="left">336</td>
<td align="left">10 M</td>
<td align="left">~5 GB</td>
</tr>
<tr>
<td align="left"><code>6h</code></td>
<td align="left">56</td>
<td align="left">1.7 M</td>
<td align="left">~0.8 GB</td>
</tr>
<tr>
<td align="left"><code>1d</code></td>
<td align="left">14</td>
<td align="left">420 k</td>
<td align="left">~200 MB</td>
</tr>
</tbody>
</table>
<p>For production fleets of 10k+ partitions, <code>6h</code> or <code>1d</code> is the right default. It requires cloning and retraining the job.</p>
<p><strong>Beyond bucket span, there are two more options:</strong></p>
<ul>
<li>
<p><strong>Shorter horizon.</strong> A 7-day forecast at <code>6h</code> gives 28 docs per partition per run — 12× fewer than the blog's default. Most capacity incidents have more than a week of warning; 14 days is comfortable headroom, not a hard requirement.</p>
</li>
<li>
<p><strong>Split the job.</strong> The single-job pattern breaks down above ~100k partitions. Split by datacenter, cloud region, or team — each job covers a bounded cardinality slice. The workflow YAML remains identical across all of them; the only changes are <code>consts.job_id</code> and <code>consts.results_index</code>. You can run one workflow per job or fan out with <code>workflow.executeAsync</code>.</p>
</li>
</ul>
<p>There is also a hard compute ceiling: the docs state that forecasts needing more than 500 MB of working memory (the spool cap) will not start at all. This is a constraint on model complexity during the projection phase, not on the number of result docs. For disk-usage patterns, it's rarely the first limit you hit — index volume and ML node memory for the anomaly job itself usually bite first.</p>
<p>For teams already running Elastic for observability, this pattern keeps capacity alerting inside the same stack as the rest of the data. One less product to evaluate, one less agent to deploy.</p>
<h2>How to enable disk capacity forecasting alerts in Kibana</h2>
<p>Enable Workflows in Kibana under <strong>Stack Management &gt; Workflows</strong> (requires Kibana 9.4+ and the <code>manage_workflows</code> privilege). Then:</p>
<ol>
<li>Create the ML job using the configuration above and start the datafeed.</li>
<li>Wait for the model to accumulate a week's worth of disk metrics.</li>
<li>Set <code>slack_connector_id</code> and <code>slack_channel</code> in the <code>consts</code> section of <a href="https://github.com/elastic/workflows/blob/main/library/workflows/disk-capacity-forecast-alert/disk-capacity-forecast-alert.yaml">disc-capacity-forecast-alert.yaml</a>. Import the YAML in Kibana and enable the workflow.</li>
</ol>
<p>On the first morning run, we'll either get a Slack alert listing hosts to investigate or a console log confirming everything is below the threshold. No more pesky surprise disk-full incidents.</p>
]]></content:encoded>
            <category>observability-labs</category>
            <enclosure url="https://www.elastic.co/observability-labs/assets/images/disk-capacity-forecasting-kibana-workflows/image3.png" length="0" type="image/png"/>
        </item>
    </channel>
</rss>