Series · FleetKeeper: operational automation for Intune-managed Windows fleets · Part 1 of 3

Intune · Remediations · Azure Functions · Windows · Automation

FleetKeeper, part 1: tracking remediation results

A walkthrough of device plans, detection outcomes and stored state, showing how cooldowns and a five-failure stop keep repairs from retrying forever.

Drafted
Published
Updated
Reading time
6 min
Author
Roberto Gramellini

Source: FleetKeeper (private repository)

  • Intune remediations
  • Intune automation
  • device state
  • rule engine
  • Logs Ingestion API
  • Azure Functions
  • Windows fleet management
A detect, flag and remediate loop around a device state database, titled FleetKeeper.

FleetKeeper, part 1: tracking remediation results

This series shares FleetKeeper's remediation design and practical code examples.

The scenario

Two hundred laptops have been up for more than fourteen days. Users choose "Shut down", but Fast Startup turns it into hibernation; the machines do not really reboot and patches keep waiting. A remediation fixes most of them. On a few, it fails every day for a month. Unless something calls attention to those failures, the schedule carries on quite happily.

I would want to know which devices were fixed, when the others last succeeded, and how long they have been failing. Also, can we try twenty devices before the rest, and stop the rollout if the results look bad? Those are fleet-operation questions, not questions about PowerShell syntax.

Static Intune Remediations give us the scheduled script. They do not, by themselves, supply the remembered state and rollout decisions described here. FleetKeeper adds that layer.

What it is

FleetKeeper runs on Azure for Intune-managed Windows fleets, without third-party agents. The pieces are:

  • a universal PowerShell client, plus an optional native C++ Windows service, that runs detect and remediate steps on the endpoint;
  • a .NET 10 backend on Azure Functions that decides what each device should run, and records what happened;
  • per-device, per-feature state in Azure Table Storage;
  • a feature catalog in JSON that describes detections, remediation rules, rings and exclusions;
  • outcome telemetry in Log Analytics through the Logs Ingestion API, ready for KQL and Workbooks.

The important choice is where policy lives: the endpoint does not decide, the server does. The device asks for a plan, runs it, and reports the outcome. The server uses state and rules to choose the next work, instead of asking each endpoint to remember the entire policy.

The FleetKeeper loop: the endpoint asks for a plan, executes it and posts outcomes; Service Bus feeds a rule engine that updates device state, which shapes the next plan

Figure 1: plan out, outcome in, state updated, next plan shaped by the state.

The gap: scripts without memory

A stateless detection script is fine for a one-off check. Across a fleet, I would also need to remember what happened after the check. That is the difference this table tries to make explicit:

Need Plain remediation script FleetKeeper
Remember the last attempt No Yes, per device and per feature
Avoid hammering a device No Cooldown in hours
Stop retrying a lost cause No Give up after consecutive failures
Skip some devices Assignment filters only Exclusion gate plus catalog exclusions
Roll out gradually Manual group juggling Rings, waves, percentages (part 2)
Stop everything fast Unassign the script Automatic halt on error rate (part 2)
Correlate with telemetry Separate effort Same pipeline, same tables

This is aimed at the person responsible for a few thousand endpoints, especially when a successful scheduled run is being mistaken for a successful repair. "It ran" and "it worked" deserve separate columns.

The loop, input to output

  1. Ask for work. The device calls /api/plan using mutual TLS and receives the features for the detect phase.
  2. Report detection. Each detection runs and the client posts its outcome: Compliant, NonCompliant, Error.
  3. Keep the result. Through Service Bus, the rule engine updates stored state. The feature's rule determines whether to flag that device for remediation.
  4. Offer the repair. The next plan can include the flagged remediation, but only after cooldown, exclusions, and rollout gates allow it.
  5. Finish, or ask for help. Remediated clears the flag. Repeated failures eventually reach a terminal state requiring a human.

For the native agent, the cycle is every 24 hours, tunable by the server, with remediation before detection. A separate lightweight heartbeat goes out every 15 minutes. The device counts as online until two intervals are missed, late until three, then offline. A missing outcome should not look like a healthy device just because nobody reported an error.

The catalog: policy as data

The policy is in feature-catalog.json. Detection and the rule for its remediation sit beside each other, so this behavior does not require editing the client code:

{
  "name": "CheckUptime",
  "phase": "detect",
  "parameters": { "thresholdDays": 14 },
  "targetFilter": { "includeAll": true, "excludeDeviceIds": [] },
  "remediationRule": {
    "remediateFeature": "DisableFastStartup",
    "triggerOnStatus": ["NonCompliant"],
    "minConsecutiveNonCompliant": 1,
    "cooldownHours": 168
  }
}

In practical terms: uptime over fourteen days flags the device for DisableFastStartup, no more than once a week. The remediation sets HiberbootEnabled=0 to disable Fast Startup. It does not force a reboot. I would keep that distinction visible to the support team; changing the setting and restarting the user's laptop are not the same intervention.

Another catalog feature waits for two consecutive non-compliant results before acting. Here, changing tolerance for noise means changing a number, not rebuilding the detection path.

The rule engine: where memory pays off

The cooldown calculation is short enough to inspect directly. Take the last remediation time, add the configured window, and compare with now:

private static bool IsInCooldown(DeviceFeatureStateEntity entity, RemediationRule rule, out DateTimeOffset cooldownUntil)
{
    cooldownUntil = default;
    if (rule.CooldownHours <= 0 || entity.LastRemediationAt is null) return false;
    cooldownUntil = entity.LastRemediationAt.Value.AddHours(rule.CooldownHours);
    return DateTimeOffset.UtcNow < cooldownUntil;
}

The more consequential state change comes after the remediation result:

if (string.Equals(result.Status, UniversalConstants.Status.Remediated, StringComparison.OrdinalIgnoreCase))
{
    entity.NeedsRemediation = false;
    entity.NeedsRemediationSince = null;
    entity.ConsecutiveRemediationFailures = 0;
    return;
}

if (string.Equals(result.Status, UniversalConstants.Status.RemediationFailed, StringComparison.OrdinalIgnoreCase))
{
    entity.ConsecutiveRemediationFailures++;
    const int defaultMax = 5;
    if (entity.ConsecutiveRemediationFailures >= defaultMax)
    {
        entity.RemediationGivenUp = true;
        entity.NeedsRemediation = false;
    }
}

Success resets the failure counter. After five consecutive failures, RemediationGivenUp is set and that device receives no more of this remediation until an administrator clears it. The log says "admin must clear". I like this boundary: five failed attempts are a reason to investigate, not to schedule attempt number six and promise to look tomorrow.

Telemetry without the retired API

For telemetry, outcomes reach Log Analytics through the Logs Ingestion API. This needs a Data Collection Endpoint, a Data Collection Rule, and Entra ID authentication:

// LogsIngestionClient for the Logs Ingestion API (DCE + DCR, Entra ID authentication)
builder.Services.AddSingleton(sp =>
{
    var dceEndpoint = config["IntuneUp:LogAnalytics:DceEndpoint"];
    if (string.IsNullOrWhiteSpace(dceEndpoint))
        throw new InvalidOperationException("IntuneUp:LogAnalytics:DceEndpoint must be configured");
    return new LogsIngestionClient(new Uri(dceEndpoint), defaultCredential);
});

The LogCollector series describes the same migration. The HTTP Data Collector API was retired in September 2026; it is not a foundation for new work. Here the ingestion client authenticates with Entra, not a workspace shared key.

Limits worth stating

  • Stored state depends on reported outcomes. No check-in means no state from that device; heartbeat and late/offline tracking are there to make the absence visible.
  • Five is the defensive give-up default in this code. Do not assume it is an exposed tuning setting.

Next

Remembering failures is only part of operating the loop. Part 2 looks at rings, waves, exclusions, and automatic halt. For the Azure infrastructure and trust model underneath them, continue to Part 3.