• Target Architecture: Automated Microsoft Graph Telemetry Harvester → Azure Blob Storage Lake → PowerBI Star Schema
• Authentication Model: Entra ID App Registration with Certificate Credentials (Least Privilege, Secretless)
• Graph API Scopes:
DeviceManagementManagedDevices.Read.All, DeviceManagementApps.Read.All• Core Telemetry Dimensions: Hardware Lifecycle (TPM 2.0, Battery Wear %, Storage Capacity) & Software Inventory (Detected Apps + Device Bridge)
• Business Value: Eliminates native Intune portal pagination bottlenecks, provides multi-year historical trend analysis, and surfaces actionable executive KPIs for fleet replacement cycles and security posture.
Endpoint telemetry extraction is a foundational capability for proactive enterprise modern management that allows IT engineering teams to monitor fleet health, security baselines, and hardware life cycles beyond the built-in boundaries of the Microsoft Intune console. While the Microsoft Intune Admin Center provides operational reports for device configuration, endpoint compliance baselines (see our guide on proactive admin auditing with PowerShell to detect unauthorized local admins), and Windows Update rings, enterprise IT architects, security auditors, and asset lifecycle managers rapidly hit severe administrative bottlenecks. Native Intune console blades paginate slowly, enforce rigid 10,000-row export caps, restrict multi-tenant correlation, and make cross-referencing installed desktop application versions with physical hardware wear metrics extraordinarily difficult.
To overcome these portal limitations and unlock executive-grade business intelligence, organizations must extract raw endpoint telemetry directly from the Microsoft Graph API and feed it into a structured, relational reporting model. This engineering blueprint provides the complete, production-hardened PowerShell automation pipeline to harvest hardware inventory, battery degradation metrics, storage capacity, and detected application catalogs into PowerBI—featuring automated pagination, exponential backoff for HTTP 429 throttling, and a resilient Star Schema data model.
Operational Comparison: Native Intune Portal vs. Graph-Powered PowerBI Pipeline
| Operational Capability | Native Intune Admin Center | Automated Graph API + PowerBI Pipeline |
|---|---|---|
| Data Retention & Trends | Transient; only represents current state snapshot | Multi-year historical trend lake (track battery degradation & fleet expansion over time) |
| Export & Query Limits | Hard limits at 10,000 rows; frequent UI timeouts | Unlimited scale via automated @odata.nextLink pagination & batch processing |
| Hardware & App Correlation | Separated into disconnected blades; manual VLOOKUP required | Relational Star Schema linking hardware models, users, and vulnerable software in one click |
| Custom KPI Calculations | Restricted to built-in Microsoft summary cards | Custom DAX Measures (e.g., Fleet Battery Health < 75%, Storage Pressure > 90%) |
| API Throttling Handling | Manual page refresh on browser session disconnects | Automated Exponential Backoff honoring HTTP 429 Retry-After headers |
| Multi-Tenant Aggregation | Requires tenant switching in separate browser sessions | Unified multi-tenant dashboard with row-level security (RLS) per subsidiary |
Deep Dive: Core Microsoft Graph Telemetry Endpoints
Building an enterprise-grade reporting pipeline requires querying three foundational Graph resources. While the Microsoft Graph v1.0 endpoint exposes core inventory records, deep hardware telemetry (such as battery wear percentage and TPM specification levels) resides in the beta schema.
GET /deviceManagement/managedDevices (Hardware & State Telemetry)
Exposes full device identity and system metadata. Key attributes include:
id: Unique Intune Device GUID (Primary Key for device dimensions).deviceName,serialNumber,manufacturer,model: Core hardware asset tracking.operatingSystem,osVersion: OS build tracking (crucial for Windows 11 23H2/24H2 readiness).totalStorageSpaceInBytes,freeStorageSpaceInBytes: Storage capacity tracking.hardwareInformation: Nested container containingtpmSpecificationVersionandbatteryHealthPercentage(supported on compatible Windows endpoints with Endpoint Analytics enabled).lastSyncDateTime,complianceState,userPrincipalName: Operational health and ownership context. For endpoints reporting stale check-ins, learn how to configure an Intune device sync force refresh to accelerate telemetry synchronization.
GET /deviceManagement/detectedApps (Application Master Catalog)
Maintains an aggregated software catalog of all installed software discovered on enrolled corporate workstations. Key attributes include:
id: Unique Application GUID (Primary Key for software dimension).displayName,publisher,version: Identifies software titles, major revisions, and vendors.sizeInByte: Estimated installation footprint on disk.deviceCount: Summary count of corporate devices reporting this specific build.
GET /deviceManagement/managedDevices('{id}')/detectedApps (The Relational Bridge)
This endpoint provides the indispensable relational mapping linking specific devices to their installed software catalog. Without extracting this sub-resource, reporting models can only display aggregate software counts rather than answering critical security questions such as: "Which exact workstations are running an unpatched, vulnerable build of 7-Zip or Chrome?"
Production-Grade PowerShell Telemetry Harvester Script
The PowerShell script below implements enterprise-grade resilience patterns: certificate authentication via an Entra ID App Registration, automated @odata.nextLink pagination across large multi-thousand device fleets, safe null-handling for mobile and virtual endpoints, true exponential backoff with HTTP 429 Retry-After parsing, and export of the complete Star Schema tables (including the Device-to-App bridge):
<#
.SYNOPSIS
Production-Grade Intune Telemetry Harvester for PowerBI Analytics.
.DESCRIPTION
Extracts hardware inventory, detected applications, and device-to-app
associations from Microsoft Graph API with automatic pagination, exponential
backoff on HTTP 429 throttling, and outputs Star Schema CSVs for PowerBI.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TenantId,
[Parameter(Mandatory = $true)]
[string]$ClientId,
[Parameter(Mandatory = $true)]
[string]$CertThumbprint,
[Parameter(Mandatory = $false)]
[string]$OutputDir = "$PSScriptRoot\Export",
[Parameter(Mandatory = $false)]
[switch]$IncludeAppBridge
)
# Ensure output directory structure exists
if (-not (Test-Path $OutputDir)) {
New-Item -Path $OutputDir -ItemType Directory -Force | Out-Null
}
# --- Resilient Graph API Invocation with HTTP 429 & 503 Exponential Backoff ---
function Invoke-ResilientGraphRequest {
param(
[string]$Uri,
[int]$MaxRetries = 5
)
$retryCount = 0
while ($retryCount -lt $MaxRetries) {
try {
return (Invoke-MgGraphRequest -Method GET -Uri $Uri -ErrorAction Stop)
} catch {
$statusCode = 0
if ($_.Exception.Response) {
$statusCode = [int]$_.Exception.Response.StatusCode
}
if ($statusCode -eq 429 -or $statusCode -eq 503) {
$retryAfter = $_.Exception.Response.Headers['Retry-After']
$waitSec = if ($retryAfter) { [int]$retryAfter } else { [math]::Pow(2, $retryCount) * 2 }
Write-Warning "[Rate Limited: HTTP $statusCode] Pausing for $waitSec seconds (Attempt $($retryCount + 1)/$MaxRetries)..."
Start-Sleep -Seconds $waitSec
$retryCount++
} else {
throw $_
}
}
}
throw "Fatal: Max Graph API retries exceeded for URI: $Uri"
}
Write-Host ">>> [1/4] Authenticating to Microsoft Graph via Certificate..." -ForegroundColor Cyan
try {
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertThumbprint -NoWelcome -ErrorAction Stop
} catch {
Write-Error "Authentication Failed: Ensure the certificate is installed in Cert:\CurrentUser\My or Cert:\LocalMachine\My."
exit 1
}
# ==============================================================================
# 1. Harvest Dim_Devices (Hardware & Configuration Telemetry)
# ==============================================================================
Write-Host ">>> [2/4] Querying /deviceManagement/managedDevices with pagination..." -ForegroundColor Cyan
$deviceUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,serialNumber,manufacturer,model,operatingSystem,osVersion,totalStorageSpaceInBytes,freeStorageSpaceInBytes,userPrincipalName,complianceState,lastSyncDateTime,enrolledDateTime,chassisType,hardwareInformation"
$allDevices = [System.Collections.Generic.List[PSObject]]::new()
do {
$response = Invoke-ResilientGraphRequest -Uri $deviceUri
if ($response.value) {
foreach ($d in $response.value) {
$totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round(($d.totalStorageSpaceInBytes / 1GB), 2) } else { 0 }
$freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round(($d.freeStorageSpaceInBytes / 1GB), 2) } else { 0 }
$usedPct = if ($totalGB -gt 0) { [math]::Round((($totalGB - $freeGB) / $totalGB) * 100, 1) } else { 0 }
# Safe navigation for hardwareInformation (protects against null errors on mobile & VMs)
$hw = $d.hardwareInformation
$tpm = if ($hw) { $hw.tpmSpecificationVersion } else { $null }
$battery = if ($hw) { $hw.batteryHealthPercentage } else { $null }
$allDevices.Add([PSCustomObject]@{
DeviceId = $d.id
DeviceName = $d.deviceName
SerialNumber = $d.serialNumber
Manufacturer = $d.manufacturer
Model = $d.model
OS = $d.operatingSystem
OSVersion = $d.osVersion
TotalStorageGB = $totalGB
FreeStorageGB = $freeGB
StorageUsedPct = $usedPct
UserPrincipalName = $d.userPrincipalName
ComplianceState = $d.complianceState
LastSyncDateTime = $d.lastSyncDateTime
EnrolledDateTime = $d.enrolledDateTime
TPMVersion = $tpm
BatteryHealthPct = $battery
})
}
}
$deviceUri = $response.'@odata.nextLink'
} while ($deviceUri)
$devicesCsv = "$OutputDir\Dim_Devices.csv"
$allDevices | Export-Csv -Path $devicesCsv -NoTypeInformation -Encoding UTF8
Write-Host "[+] Exported $($allDevices.Count) endpoint records to $devicesCsv" -ForegroundColor Green
# ==============================================================================
# 2. Harvest Dim_Applications (Detected Software Catalog)
# ==============================================================================
Write-Host ">>> [3/4] Querying /deviceManagement/detectedApps with pagination..." -ForegroundColor Cyan
$appUri = "https://graph.microsoft.com/beta/deviceManagement/detectedApps?`$select=id,displayName,version,publisher,sizeInByte,deviceCount"
$allApps = [System.Collections.Generic.List[PSObject]]::new()
do {
$appResponse = Invoke-ResilientGraphRequest -Uri $appUri
if ($appResponse.value) {
foreach ($a in $appResponse.value) {
$allApps.Add([PSCustomObject]@{
AppId = $a.id
DisplayName = $a.displayName
Version = $a.version
Publisher = $a.publisher
SizeMB = if ($a.sizeInByte) { [math]::Round(($a.sizeInByte / 1MB), 2) } else { 0 }
DeviceCount = $a.deviceCount
})
}
}
$appUri = $appResponse.'@odata.nextLink'
} while ($appUri)
$appsCsv = "$OutputDir\Dim_Applications.csv"
$allApps | Export-Csv -Path $appsCsv -NoTypeInformation -Encoding UTF8
Write-Host "[+] Exported $($allApps.Count) application titles to $appsCsv" -ForegroundColor Green
# ==============================================================================
# 3. Harvest Fact_DeviceAppBridge (Relational Association)
# ==============================================================================
if ($IncludeAppBridge) {
Write-Host ">>> [4/4] Extracting Fact_DeviceAppBridge (Device-to-App mappings)..." -ForegroundColor Cyan
$bridgeData = [System.Collections.Generic.List[PSObject]]::new()
$processed = 0
foreach ($dev in $allDevices) {
$processed++
if ($processed % 25 -eq 0) {
Write-Host " Processing associations: $processed of $($allDevices.Count) devices..." -ForegroundColor Gray
}
$devAppUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$($dev.DeviceId)')/detectedApps?`$select=id"
try {
$devApps = Invoke-ResilientGraphRequest -Uri $devAppUri
if ($devApps.value) {
foreach ($da in $devApps.value) {
$bridgeData.Add([PSCustomObject]@{
DeviceId = $dev.DeviceId
AppId = $da.id
})
}
}
} catch {
Write-Warning "Skipped application inventory for device $($dev.DeviceName): $($_.Exception.Message)"
}
}
$bridgeCsv = "$OutputDir\Fact_DeviceAppBridge.csv"
$bridgeData | Export-Csv -Path $bridgeCsv -NoTypeInformation -Encoding UTF8
Write-Host "[+] Exported $($bridgeData.Count) associations to $bridgeCsv" -ForegroundColor Green
}
Disconnect-MgGraph | Out-Null
Write-Host ">>> Telemetry Harvester Completed Successfully." -ForegroundColor Green
Designing the PowerBI Star Schema Relationship Model
In analytical business intelligence, flattening hardware properties and software catalogs into a single denormalized spreadsheet results in massive data duplication, high memory consumption, and slow visual rendering. A pure Star Schema maintains clean separation between entity dimensions and transactional facts:
| Table Name | Schema Role | Primary Key / Foreign Key | Relationship & Cardinality | Analytical Focus |
|---|---|---|---|---|
Dim_Devices |
Dimension | DeviceId (PK) |
One-to-Many (1:*) to Fact Bridge | Fleet model distribution, Windows 11 TPM 2.0 readiness, disk space pressure, battery wear analysis. |
Dim_Applications |
Dimension | AppId (PK) |
One-to-Many (1:*) to Fact Bridge | Software portfolio governance, unapproved vendor discovery, legacy browser & runtime tracking. |
Fact_DeviceAppBridge |
Bridge Fact | DeviceId (FK), AppId (FK) |
Many-to-One (*:1) to Dimensions | Cross-filtering engine: identifies exact devices running vulnerable builds or unapproved software packages. |
Dim_Date |
Dimension | DateKey (PK) |
One-to-Many (1:*) to Device Enrollment | Enrollment cohort velocity, stale inactive devices (>30 days without sync), hardware replacement forecasting. |
Essential DAX Measures for Enterprise Executive Dashboards
Once your Star Schema is loaded into PowerBI Desktop, implement the following DAX measures to power executive KPI cards, fleet health gauges, and security risk matrices:
// 1. Total Managed Fleet Size
Total Enrolled Devices =
COUNTROWS(Dim_Devices)
// 2. Storage Pressure Exposure (Endpoints with < 20 GB free space or >= 90% full)
Devices Low Storage =
CALCULATE(
COUNTROWS(Dim_Devices),
Dim_Devices[FreeStorageGB] < 20 || Dim_Devices[StorageUsedPct] >= 90
)
// 3. Low Storage Fleet Percentage
Fleet Low Storage Rate =
DIVIDE([Devices Low Storage], [Total Enrolled Devices], 0)
// 4. Critical Battery Degradation Exposure (Health < 75%)
Battery Replacement Required =
CALCULATE(
COUNTROWS(Dim_Devices),
Dim_Devices[BatteryHealthPct] > 0 && Dim_Devices[BatteryHealthPct] < 75
)
// 5. Windows 11 Hardware TPM 2.0 Compliance Rate
TPM 2.0 Compliance % =
DIVIDE(
CALCULATE(
COUNTROWS(Dim_Devices),
Dim_Devices[TPMVersion] >= "2.0"
),
CALCULATE(
COUNTROWS(Dim_Devices),
NOT(ISBLANK(Dim_Devices[TPMVersion]))
),
0
)
// 6. Stale Inactive Endpoints (No sync in 30+ days)
Stale Inactive Devices =
CALCULATE(
COUNTROWS(Dim_Devices),
DATEDIFF(Dim_Devices[LastSyncDateTime], TODAY(), DAY) > 30
)
Automated Ingestion via Power Query (M Script)
For cloud-hosted data pipelines, store the daily CSV snapshots in an Azure Blob Storage container (e.g. https://yourstorage.blob.core.windows.net/intune-telemetry/). PowerBI can ingest the latest snapshot automatically using the following Power Query (M) pattern:
let
// Connect to Azure Blob Storage Container
Source = AzureStorage.Blobs("yourstorageaccount"),
TelemetryContainer = Source{[Name="intune-telemetry"]}[Data],
// Filter to the latest device inventory snapshot
DeviceFiles = Table.SelectRows(TelemetryContainer, each Text.Contains([Name], "Dim_Devices")),
SortedFiles = Table.Sort(DeviceFiles, {{"Date modified", Order.Descending}}),
LatestFile = SortedFiles{0}[Content],
// Parse CSV with UTF-8 encoding
ImportedCsv = Csv.Document(LatestFile, [Delimiter=",", Columns=16, Encoding=65001, QuoteStyle=QuoteStyle.None]),
PromotedHeaders = Table.PromoteHeaders(ImportedCsv, [PromoteAllScalars=true]),
// Enforce Strongly Typed Schema
TypedTable = Table.TransformColumnTypes(PromotedHeaders, {
{"DeviceId", type text},
{"DeviceName", type text},
{"SerialNumber", type text},
{"Manufacturer", type text},
{"Model", type text},
{"OS", type text},
{"OSVersion", type text},
{"TotalStorageGB", type number},
{"FreeStorageGB", type number},
{"StorageUsedPct", type number},
{"UserPrincipalName", type text},
{"ComplianceState", type text},
{"LastSyncDateTime", type datetimezone},
{"EnrolledDateTime", type datetimezone},
{"TPMVersion", type text},
{"BatteryHealthPct", Int64.Type}
})
in
TypedTable
Security, Governance & Implementation Checklist
- Entra ID App Registration: Register a dedicated Enterprise Application in Microsoft Entra ID. Assign least-privilege application permissions:
DeviceManagementManagedDevices.Read.AllandDeviceManagementApps.Read.All. Grant tenant-wide Admin Consent. - Secretless Authentication: Create an asymmetric X.509 certificate (2048-bit RSA) and upload the public
.certo the Entra App Registration. Store the private.pfxsecurely in Azure Key Vault or the local certificate store (avoid fragile client secrets). - Runbook Orchestration: Deploy the PowerShell Harvester into an Azure Automation Account (PowerShell 7.2 Runbook) or a scheduled GitHub Actions workflow running on a daily cron schedule (e.g.
0 2 * * *at 2:00 AM UTC). - Staging Storage Lifecycle: Configure an Azure Blob Storage container with lifecycle management rules: automatically transition snapshots older than 30 days to Cool storage, and delete or archive after 180 days.
- PowerBI Service Scheduled Refresh: In PowerBI Service, configure scheduled refresh twice daily (04:00 AM and 12:00 PM) using organizational credentials against Azure Blob Storage. Configure Row-Level Security (RLS) to restrict regional support managers to their department's endpoints.