Series · LogCollector: authenticated telemetry for Intune devices · Part 2 of 3

Intune · PowerShell · Win32 apps · Resilience

LogCollector, part 2: offline delivery and Intune packaging

How the client keeps unsent telemetry in a bounded spool, retries with fresh signatures and ships through Intune with detection generated from the installed configuration.

Drafted
Published
Updated
Reading time
7 min
Author
Roberto Gramellini

Source: LogCollector · revision 680d7bdf

  • offline-first
  • spool
  • retry
  • PowerShell
  • Intune Win32 app
  • IntuneWin
  • detection script
  • scheduled task
  • quarantine
  • Pester
A stack of queued files on disk with a disconnected plug that reconnects and sends packets to the cloud.

Part 1 dealt with the cloud side of LogCollector. The HTTP Data Collector API used by many Intune scripts was retired in September 2026, and distributing its shared workspace key was a problem even before retirement. The backend now requires certificate-signed requests rather than trusting the sender. Here I want to look at what happens on the device, particularly when it cannot send anything.

A legacy fire-and-forget script loses its data when the POST fails. An authenticated pipeline does not remove that risk; it adds explicit failure cases such as expired timestamps and rejected payloads, alongside the usual unreachable endpoint. The client needs somewhere to keep unsent data and a sensible delivery policy. It also needs an Intune package that installs the right configuration, not merely the right files.

Laptops make this less theoretical. They are offline on trains, asleep in bags, or on hotel Wi-Fi that allows little beyond port 80. I would not make the success of a collection depend on the network being available at that exact moment.

That is why collection and delivery are separate. A durable local spool sits between them: offline-first, within explicit storage and age limits.

The life of a telemetry entry

Flow from collecting telemetry to writing the spool, then draining oldest-first with a fresh signature and branching to success, transient failure and permanent failure.

Figure 1. Collect, spool, drain. Success deletes, transient failures wait, permanent failures are quarantined.

An entry is written under a .tmp name and renamed to its final name only after completion. That atomic write prevents a power cut from leaving a half-written entry presented as a valid one.

# Artifacts/MSLabs/1.15.2/2-Intune/CoreSource/Modules/InventorySpool.psm1 (lines 311-315)
$fileName = '{0}-{1}.json' -f $createdUtc.ToString('yyyyMMddTHHmmssfff'), ([guid]::NewGuid().ToString('N'))
$finalPath = Join-Path $SpoolDirectory $fileName
$tempPath = "$finalPath.tmp"

$json = $entry | ConvertTo-Json -Depth 8 -Compress

The timestamp prefix gives the filenames chronological order when sorted lexically. No extra index is needed just to find the oldest entry.

Bounded patience

Keeping data locally is useful until it fills the disk. These are the client's default limits:

# Artifacts/MSLabs/1.15.2/2-Intune/CoreSource/Modules/InventoryClient.psm1 (lines 480-490)
[Parameter(Mandatory)] [string] $SpoolDirectory,
[int] $MaxEntriesPerRun = 50,
[int] $MaxAttemptsPerEntry = 2,
[int] $MaxSpoolAgeDays = 7,
[int] $MaxSpoolEntries = 500,
[int] $MaxSpoolTotalBytes = 67108864,
[int] $MaxDeliveryAttempts = 10,
[ValidateRange(1, 300)] [int] $TimeoutSeconds = 100,
[ValidateRange(1, 900)] [int] $MaxDelaySeconds = 300,
[switch] $NoSleep,

The first limit reached applies: seven days, 500 entries, or 64 MB. A device disconnected for a month cannot retain an unlimited history, but it also should not sacrifice C: to telemetry. I would choose bounded loss over a full disk, and document that choice.

The drain order matters as much as the capacity. The function documents why backlog goes first:

# same file (lines 462-469)
<#
.SYNOPSIS
    Delivers previously spooled envelopes, oldest first.
.DESCRIPTION
    Runs BEFORE the current sample is submitted, so the backlog is the thing
    that gets the freshest connectivity rather than being starved behind new
    data. Draining stops at the first transient or auth failure: if the
    service is down, continuing would waste the run's time budget and add
    load to an already unhealthy endpoint.
#>

The first transient or authentication failure stops the drain. If the endpoint is unhealthy, another forty-nine attempts consume the run's time budget and add load without helping delivery. Better to leave the entries for the next run.

Permanent failures get a quarantine, not a retry loop

A permanently rejected payload needs different treatment. Repeating the same request will not repair it, and leaving it at the head of the queue blocks later entries. The client moves it to quarantine for operator inspection:

# same file (lines 558-564)
'Permanent' {
    $null = Move-SpoolEntryToQuarantine -Path $entry.Path -Reason ("http-{0}" -f $result.StatusCode)
    $summary.Quarantined++
    Write-InventoryTransportDiagnostic $DiagnosticSink 'SpoolQuarantined' @{
        SpoolPath = $entry.Path; Stage = 'PermanentRejection'; StatusCode = $result.StatusCode
    }
    continue
}

A stored entry needs a new signature

There is a catch with secure retries. Part 1's gates reject stale timestamps and repeated nonces, so an entry sent four days later with its original signature is wrong before the payload is even considered. The client follows a simple rule: never store a signature, always re-sign at delivery time.

# src/Client/RequestSigning.psm1 (lines 73-78)
.SYNOPSIS
    Produces the body bytes and headers for a signed submission.
.DESCRIPTION
    A fresh timestamp and nonce are generated on every call. That is required,
    not incidental: a retry (or a spool drain hours later) must re-sign, since
    the server rejects both stale timestamps and repeated nonces.

The test makes this behavior explicit, so reusing a signed request cannot quietly become a future optimization:

# tests/Pester/RequestSigning.Tests.ps1 (lines 167-175)
It 'generates a fresh nonce and timestamp on every call' {
    $first = New-SignedInventoryRequest -Uri ([Uri]'https://example.invalid/api/inventory') `
        -Body '{}' -Certificate $script:Certificate
    $second = New-SignedInventoryRequest -Uri ([Uri]'https://example.invalid/api/inventory') `
        -Body '{}' -Certificate $script:Certificate

    # Reusing either value would make a legitimate retry look like a replay.
    $first.Headers['X-Request-Nonce'] | Should -Not -Be $second.Headers['X-Request-Nonce']
    $first.Headers['X-Request-Signature'] | Should -Not -Be $second.Headers['X-Request-Signature']
}

Replay protection is doing its job here. The delivery code has to respect it, even when the retry is perfectly legitimate.

Shipping it with Intune

Packaging flow: Azure outputs feed New-IntunePackage.ps1, which produces a matching intunewin and detection script, deployed as a Core app and a dependent Inventory app.

Figure 2. Core is a dependency; producers are small and separate.

Intune deploys two Win32 apps. Core installs the client module and protected endpoint configuration; it collects nothing. Inventory depends on Core and registers the scheduled tasks. Collection runs on Wednesday and Saturday mornings with a two-hour random delay. A separate hourly task drains the spool. There is no operational prize for making ten thousand laptops arrive at 09:00 together; leave that kind of queue to the municipal office.

I would pay particular attention to the packaging script. It writes the deployment's frontend URL into Core configuration, then generates the detection script from that same configuration object during the same run. Otherwise installation and detection can disagree while both files look reasonable:

# Artifacts/MSLabs/1.15.2/2-Intune/New-IntunePackage.ps1 (lines 226-240)
$coreConfigPath = Join-Path $coreStaging 'Config.psd1'
[IO.File]::WriteAllText($coreConfigPath, (ConvertTo-ConfigurationText -Configuration $coreConfig), [Text.UTF8Encoding]::new($false))
$coreDetectionPath = Join-Path $coreStaging 'Detect.ps1'
$coreDetection = [IO.File]::ReadAllText($coreDetectionPath)
$coreDetectionMarker = '__LOGCOLLECTOR_CORE_EXPECTED_CONFIGURATION_BASE64__'
if (-not $coreDetection.Contains($coreDetectionMarker)) {
    throw 'Core detection template is missing its expected-configuration marker.'
}
$coreDetectionPayload = ConvertTo-CoreDetectionPayload -Configuration $coreConfig
$coreDetection = $coreDetection.Replace($coreDetectionMarker, $coreDetectionPayload)
[IO.File]::WriteAllText($coreDetectionPath, $coreDetection, [Text.UTF8Encoding]::new($false))

A changed endpoint therefore changes detection too: Intune sees the old configuration as non-compliant and redeploys. Earlier in the script, another guard refuses to build if the Core package and shared module versions differ. They ship together and must be bumped together, rather than relying on somebody to remember after packaging.

By the numbers

Component Version / value
Backend release 1.15.2
Core / client 1.12.0
Inventory 1.9.1
Ingestion chunk size 850 KB or less
Worker retries up to 5, 1 s base delay, 60 s cap
Tests 26 Pester files, 24 C# test files

Each Pester file runs in its own process. An accidental module import in one file cannot make the next file pass by leaving the environment prepared for it.

What I would keep in a similar client

The bounded spool is the first thing I would keep, including its oldest-first drain and stop-on-transient-failure behavior. Quarantine is just as important: a permanently bad entry must not hold the rest hostage.

Then I would keep fresh signing at delivery and generated detection from the installed configuration. Neither is much to look at on a diagram. Both prevent a retry or deployment from failing for reasons that have nothing to do with the telemetry itself.

The solution is public. Read the client, Bicep, and packaging scripts, and check whether those choices fit your own fleet before adopting them.