Collector teardown — Inventory: All Devices¶
This is the first and largest collector in the pattern. It answers the deceptively simple question "what's in the fleet?" — and the honest answer needs data from four places stitched together, without ever writing to the tenant.
Script: scripts/Collect-InventoryAllDevices.ps1.
Read-only, Managed-Identity, pre-aggregating. It writes two files to agent-data/:
Inventory_AllDevices.csv— one enriched row per device.Summary_Stats.csv— pre-computed counts, so the agent answers "how many X" from a vetted number instead of counting rows itself.
Beta endpoint. This collector uses
https://graph.microsoft.com/beta— required for the Intune Reports export jobs and a couple of device properties. Beta endpoints can change without notice; pin to/v1.0where an equivalent exists and re-verify after Graph updates.
What it collects, and from where¶
A single device row is assembled from four independent sources:
| Source | What it adds | How |
|---|---|---|
deviceManagement/managedDevices |
the base device record (name, serial, OS, compliance, owner, encryption, last sync) | paged GET |
Intune Reports — DefenderAgents |
Defender agent state, real-time protection, signature/ tamper status | server-side export job (async) |
| Device Notes field | OEM warranty start/end + friendly model | $batch reads, parsed as key=value |
Entra users |
user's org location (city / country / office) | $batch reads, de-duplicated |
The interesting part isn't any one call — it's that these have different retrieval shapes (a paged list, an async job, two batched lookups) and get joined in memory into one clean row. That's the collector's real job.
Walkthrough¶
The runbook runs in six numbered stages so the logs read like a story.
[1/6] Storage context. Connect-AzAccount -Identity signs in as the Automation
Account's Managed Identity and gets a storage context. No key, no secret.
[2/6] Defender status — via an export job. Defender agent data isn't a plain endpoint;
it's an Intune report. Invoke-IntuneExportJob POSTs to deviceManagement/reports/exportJobs,
polls until the job is completed, downloads the ZIP, extracts the CSV, and imports it. The
results are indexed into a lookup keyed by both device id and uppercased device name (the
join later tries id first, name second).
[3/6] All managed devices. One paged GET with an explicit $select — only the columns
the inventory needs, which keeps the payload (and the later CSV) lean. All platforms, no OS
filter.
[4/6] Warranty from the Notes field. Some fleets stash OEM warranty data as key=value
lines in each device's Notes field. For devices from the configured manufacturer, the
collector reads notes in batches of 20 via $batch and parses out Model,
WarrantyStartDate, WarrantyEndDate. It's a pragmatic hack around the fact that warranty
isn't a first-class Intune property — and a good example of meeting the data where it
actually lives.
[5/6] User location — batched and de-duplicated. Many devices share a user, so the
collector first reduces to unique user ids, then $batch-reads city, country,
officeLocation from Entra. Note the honest comment in the code: this is organisational
assignment, not whereabouts — where the user is filed, not where they physically are.
[6/6] Join and export. Each device row is built by joining the three lookups (Defender,
warranty, location), deriving a WarrantyState (Active/Expired/Unknown) from the parsed
end date, and writing Inventory_AllDevices.csv. Then the summary stats are computed and
written.
Techniques worth stealing¶
These are the parts that make it production-grade rather than a demo:
Raw Managed-Identity token with refresh. Instead of Connect-MgGraph, it calls the
IDENTITY_ENDPOINT directly for a token and tracks a 50-minute expiry, refreshing before
long runs age out. Get-GraphHeaders transparently re-mints the token mid-run. For a
runbook that may execute for many minutes across thousands of devices, controlling the token
lifecycle yourself avoids mid-run 401s.
Retry that honors the server. Invoke-GraphRequest refreshes-and-retries on 401, and
backs off on 429/502/503/504 — reading the Retry-After header when present and capping
the wait at 300s. This is the difference between a runbook that survives Graph throttling and
one that dies at 3 a.m.
$batch for enrichment. Warranty and location would each be one call per device/user —
thousands of round-trips. Batching 20 requests per call cuts that by ~20× and keeps the run
inside its window. (20 is the Graph $batch limit.)
A column resolver for schema drift. Get-DefValue looks up a value across a list of
candidate column names (DeviceState_loc, DeviceState, …) because export schemas differ by
tenant and version. Rather than assume one name and silently produce blanks, it tries several
and falls back to N/A. Honest gaps over confident wrong answers.
A size gate tied to the consumer. Export-AgentDataCsv refuses to upload any
agent-data/ file over ~12 MB, because the downstream Azure AI Search extraction has a ~16 MB
ceiling. The gate lives at the producer so a bloated snapshot fails loudly here instead of
silently truncating in the index.
Pre-aggregation as a trust boundary. Summary_Stats.csv is the pattern's core principle
in code: the collector groups the inventory into ~16 pre-counted breakdowns (by OS,
manufacturer, model, compliance, country, warranty, and cross-cuts like country × compliance),
caps the long-tail dimensions to top-N, and stamps a Total. The agent then reads "how many
Windows devices in Germany are non-compliant" instead of computing it over raw rows — which
is exactly how you avoid the classic "counted rows, not devices" error.
Permissions¶
Read-only, on the Managed Identity (assign once — see build-it-yourself):
DeviceManagementManagedDevices.Read.All— devices, Notes, and the reports exportUser.Read.All— the city/country/office enrichmentStorage Blob Data Contributoron the storage account — to write the CSVs (storage, not the tenant)
Every scope is read. Nothing here writes to Intune or Entra.
What we still don't know (honest gaps)¶
- The Defender join is fuzzy. When there's no device-id match it falls back to matching on uppercased device name, which can misfire on renamed or duplicate-named machines. How often that fallback fires — and how often it's wrong — is worth measuring, not assuming.
- Warranty coverage is partial. It only exists for devices whose OEM warranty was written
into Notes. Everything else is
Unknown— stated honestly in the data rather than hidden. - Location is org data, not GPS.
UserOfficeLocationis where the user is assigned in Entra; treat "devices by location" as an administrative view, not a physical one. - Beta drift. Because it rides
/beta, a Graph change can alter a property or an export schema. The column resolver softens this, but the real defense is re-running in a lab after Graph updates.
The Azure Automation → Blob CSV → Power BI reporting approach is well-established in the
community (see SMSAgent / Trevor Jones, whose MEM-reporting templates predate this repo). What
this collector adds is the read-only-by-construction discipline and the pre-aggregated
agent-data/ snapshots an AI agent can read without any live access.
Microsoft, Intune, Entra, Microsoft Graph, Defender, and Azure are trademarks of the Microsoft group of companies. Independent content; not endorsed by Microsoft.