←
Skriptbibliothek
PowerShell
06.10.2026
PowerShell
Get IntuneAppInstallFailureSummary
363_Get-IntuneAppInstallFailureSummary.ps1
<#
.SYNOPSIS
Zeigt Intune-Apps mit fehlgeschlagenen Installationen.
.DESCRIPTION
<!-- library-status:start -->
Prüfstatus: Ungeprüft
Windows- und Tenant-Abnahme ausstehend; keine pauschale Produktionsfreigabe.
<!-- library-status:end -->
Nutzt AppInstallStatusAggregate fuer eine mandantenweite Fehleruebersicht. Mit
IncludeDeviceDetails werden fuer jede betroffene App zusaetzlich die Geraetezeilen
aus DeviceInstallStatusByApp geladen. Das kann in grossen Tenants viele Exportjobs
erzeugen und ist deshalb standardmaessig deaktiviert.
.EXAMPLE
./17_Intune_Workflows/363_Get-IntuneAppInstallFailureSummary.ps1 -MinimumFailedDevicePercentage 5 -OutputPath './reports/app-failures.csv'
#>
[CmdletBinding()]
param(
[ValidateRange(0,100)][double]$MinimumFailedDevicePercentage = 0,
[switch]$IncludeDeviceDetails,
[string]$TenantId,
[switch]$SkipConnect,
[string]$OutputPath
)
# kc-bundle:graph:start sha256=168d7f232db5d14cf1be94255b5d535f3739213158e781a4214040a070571864
# Eingebettete Hilfslogik aus Common/IntuneLibrary.psm1; durch tools/bundle-script-dependencies.mjs gepflegt.
New-Module -Name IntuneLibrary -ScriptBlock {
#requires -Version 5.1
Set-StrictMode -Version Latest
function Connect-IlGraph {
[CmdletBinding()]
param([Parameter(Mandatory)][string[]]$Scopes, [string]$TenantId, [switch]$SkipConnect)
if (-not (Get-Command Get-MgContext -ErrorAction SilentlyContinue)) {
throw 'Microsoft.Graph.Authentication fehlt. Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
}
$context = Get-MgContext
if (-not $SkipConnect -and (-not $context -or ($TenantId -and $context.TenantId -ne $TenantId))) {
$arguments = @{ Scopes = $Scopes; ErrorAction = 'Stop'; NoWelcome = $true; ContextScope = 'Process' }
if ($TenantId) { $arguments.TenantId = $TenantId }
Connect-MgGraph @arguments | Out-Null
$context = Get-MgContext
}
if (-not $context) { throw 'Keine aktive Graph-Sitzung.' }
if ($TenantId -and $context.TenantId -ne $TenantId) { throw 'Die aktive Graph-Sitzung gehoert zu einem anderen Tenant.' }
if ($context.AuthType -eq 'Delegated') {
$missing = @($Scopes | Where-Object { $_ -notin $context.Scopes })
if ($missing.Count) { throw "Der Sitzung fehlen angeforderte Scopes: $($missing -join ', '). Neu mit diesen Scopes anmelden." }
}
}
function Assert-IlGraphUri {
param([Parameter(Mandatory)][string]$Uri)
$parsed = [uri]$Uri
if (-not $parsed.IsAbsoluteUri -or $parsed.Scheme -ne 'https' -or $parsed.Host -ne 'graph.microsoft.com' -or $parsed.UserInfo -or $parsed.Port -ne 443) {
throw 'Nur HTTPS-Anfragen an graph.microsoft.com sind erlaubt (Global Cloud).'
}
if ($parsed.AbsolutePath -notmatch '^/(v1.0|beta)/') { throw 'Graph-API-Version fehlt.' }
}
function Invoke-IlGraph {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Uri,
[ValidateSet('GET','POST','PATCH','DELETE')][string]$Method = 'GET',
[object]$Body,
[ValidateRange(0,8)][int]$MaxRetries = 4
)
Assert-IlGraphUri $Uri
for ($attempt = 0; ; $attempt++) {
try {
$arguments = @{ Uri = $Uri; Method = $Method; OutputType = 'PSObject'; ErrorAction = 'Stop' }
if ($PSBoundParameters.ContainsKey('Body')) {
$arguments.Body = ConvertTo-Json -InputObject $Body -Depth 100 -Compress
$arguments.ContentType = 'application/json'
}
return Invoke-MgGraphRequest @arguments
} catch {
$status = 0
$delay = [math]::Min(60, [math]::Pow(2, $attempt))
if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
$response = $_.Exception.Response
if ($response.PSObject.Properties['StatusCode']) { $status = [int]$response.StatusCode }
if ($response.PSObject.Properties['Headers'] -and $response.Headers) {
try {
$retryAfter = $response.Headers.RetryAfter
if ($retryAfter.Delta) { $delay = [math]::Ceiling($retryAfter.Delta.TotalSeconds) }
elseif ($retryAfter.Date) { $delay = [math]::Ceiling(($retryAfter.Date - [DateTimeOffset]::UtcNow).TotalSeconds) }
} catch { Write-Verbose 'Retry-After nicht lesbar; exponentieller Backoff.' }
}
}
# Mutationen niemals automatisch wiederholen: ihre Annahme kann unklar sein.
if ($Method -ne 'GET' -or $status -notin @(429,503,504) -or $attempt -ge $MaxRetries) { throw }
if ($delay -gt 300) { throw 'Server fordert mehr als 300 Sekunden Wartezeit; Lauf spaeter erneut starten.' }
Start-Sleep -Seconds ([math]::Max(1,$delay))
}
}
}
function Get-IlGraphCollection {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Uri, [ValidateRange(1,100000)][int]$MaxPages = 10000)
$seen = @{}
$rows = New-Object 'System.Collections.Generic.List[object]'
while ($Uri) {
if ($seen.ContainsKey($Uri)) { throw 'Wiederholter Graph-nextLink; unvollstaendige Abfrage verworfen.' }
if ($seen.Count -ge $MaxPages) { throw 'Seitenlimit erreicht; unvollstaendige Abfrage verworfen.' }
$seen[$Uri] = $true
$page = Invoke-IlGraph -Uri $Uri
if (-not $page -or -not $page.PSObject.Properties['value']) { throw "Keine Graph-Collection: $Uri" }
foreach ($row in @($page.value)) { if ($null -ne $row) { $rows.Add($row) } }
$Uri = if ($page.PSObject.Properties['@odata.nextLink']) { [string]$page.'@odata.nextLink' } else { $null }
}
return $rows.ToArray()
}
function ConvertTo-IlSegment {
param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Value)
return [uri]::EscapeDataString($Value)
}
function Resolve-IlDevice {
[CmdletBinding(DefaultParameterSetName='Name')]
param(
[Parameter(Mandatory,ParameterSetName='Id')][string]$DeviceId,
[Parameter(Mandatory,ParameterSetName='Name')][string]$DeviceName,
[Parameter(Mandatory,ParameterSetName='Serial')][string]$SerialNumber
)
$base = 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices'
if ($DeviceId) { return Invoke-IlGraph -Uri ($base + '/' + (ConvertTo-IlSegment $DeviceId)) }
$field = if ($PSCmdlet.ParameterSetName -eq 'Serial') { 'serialNumber' } else { 'deviceName' }
$value = if ($SerialNumber) { $SerialNumber } else { $DeviceName }
$filter = [uri]::EscapeDataString("$field eq '$($value.Replace("'","''"))'")
$devices = @(Get-IlGraphCollection -Uri ($base + '?$filter=' + $filter))
if ($devices.Count -ne 1) { throw "$($devices.Count) Geraete gefunden. Eine eindeutige DeviceId verwenden." }
return $devices[0]
}
function Get-IlProperty {
param([AllowNull()][object]$Object, [Parameter(Mandatory)][string]$Name, [object]$Default = $null)
if ($null -eq $Object) { return $Default }
if ($Object -is [System.Collections.IDictionary]) {
if ($Object.Contains($Name)) { return $Object[$Name] }
} elseif ($Object.PSObject.Properties[$Name]) { return $Object.$Name }
return $Default
}
function New-IlFinding {
param([string]$Check, [ValidateSet('OK','Auffaellig','Nicht anwendbar','Nicht pruefbar')][string]$Status,
[string]$ObjectId, [string]$Detail, [object]$Data)
[pscustomobject][ordered]@{ Check=$Check; Status=$Status; ObjectId=$ObjectId; Detail=$Detail; Data=$Data }
}
function Export-IlResult {
[CmdletBinding()]
param([AllowNull()][object]$Data, [string]$OutputPath)
if ($OutputPath) {
$parent = Split-Path $OutputPath -Parent
if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
if ([IO.Path]::GetExtension($OutputPath) -eq '.csv') {
@($Data) | Export-Csv -LiteralPath $OutputPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
} else {
ConvertTo-Json -InputObject @($Data) -Depth 100 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 -ErrorAction Stop
}
}
return $Data
}
Export-ModuleMember -Function Connect-IlGraph,Invoke-IlGraph,Get-IlGraphCollection,ConvertTo-IlSegment,Resolve-IlDevice,Get-IlProperty,New-IlFinding,Export-IlResult
} | Import-Module -Scope Local -Force
# kc-bundle:graph:end
# kc-bundle:report:start sha256=7528259aa984c181a8e114d1586c9b91eb8d521cfb6233b4b0144570c888f49a
# Eingebettete Hilfslogik aus Common/IntuneReportLibrary.psm1; durch tools/bundle-script-dependencies.mjs gepflegt.
New-Module -Name IntuneReportLibrary -ScriptBlock {
#requires -Version 5.1
Set-StrictMode -Version Latest
function Assert-IlExportDownloadUri {
param([Parameter(Mandatory)][string]$Uri)
$parsed = [uri]$Uri
if (-not $parsed.IsAbsoluteUri) {
throw 'Ungueltige Export-Downloadadresse. Erwartet wird HTTPS auf Azure Blob Storage.'
}
$hostName = $parsed.DnsSafeHost.ToLowerInvariant()
$allowedHost = $hostName.EndsWith('.blob.core.windows.net') -or $hostName.EndsWith('.blob.storage.azure.net')
if ($parsed.Scheme -ne 'https' -or $parsed.UserInfo -or $parsed.Port -ne 443 -or -not $allowedHost) {
throw 'Ungueltige Export-Downloadadresse. Erwartet wird HTTPS auf Azure Blob Storage.'
}
}
function Invoke-IlReportExport {
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidatePattern('^[A-Za-z0-9]+$')][string]$ReportName,
[string]$Filter,
[string[]]$Select,
[ValidateSet('v1.0','beta')][string]$ApiVersion = 'beta',
[ValidateRange(10,1800)][int]$MaxWaitSeconds = 300,
[ValidateRange(1,30)][int]$PollIntervalSeconds = 3
)
$base = "https://graph.microsoft.com/$ApiVersion/deviceManagement/reports/exportJobs"
$body = [ordered]@{ reportName=$ReportName; format='csv' }
if ($Filter) { $body.filter = $Filter }
if ($Select -and $Select.Count) { $body.select = @($Select) }
$job = IntuneLibraryInvoke-IlGraph -Uri $base -Method POST -Body $body
$jobId = [string](IntuneLibraryGet-IlProperty $job 'id')
if (-not $jobId) { throw "Exportjob fuer $ReportName lieferte keine ID." }
$deadline = [datetime]::UtcNow.AddSeconds($MaxWaitSeconds)
$jobUri = "$base/$(IntuneLibraryConvertTo-IlSegment $jobId)"
do {
$state = IntuneLibraryInvoke-IlGraph -Uri $jobUri
$status = [string](IntuneLibraryGet-IlProperty $state 'status')
if ($status -eq 'completed') { break }
if ($status -in @('failed','unknown')) {
$reason = [string](IntuneLibraryGet-IlProperty $state 'localizedFailureReason' (IntuneLibraryGet-IlProperty $state 'error'))
if (-not $reason) { $reason = 'kein Fehlertext gemeldet' }
throw "Exportjob fuer $ReportName fehlgeschlagen: $reason"
}
if ([datetime]::UtcNow -ge $deadline) { throw "Zeitlimit fuer Exportjob $ReportName erreicht (Status: $status)." }
Start-Sleep -Seconds $PollIntervalSeconds
} while ($true)
$downloadUri = [string](IntuneLibraryGet-IlProperty $state 'url')
if (-not $downloadUri) { throw "Abgeschlossener Exportjob fuer $ReportName lieferte keine Downloadadresse." }
Assert-IlExportDownloadUri -Uri $downloadUri
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("kc-intune-export-" + [guid]::NewGuid().ToString('N'))
$archivePath = Join-Path $temporaryRoot 'report.zip'
$extractPath = Join-Path $temporaryRoot 'content'
try {
New-Item -ItemType Directory -Path $temporaryRoot -Force -ErrorAction Stop | Out-Null
Invoke-WebRequest -Uri $downloadUri -OutFile $archivePath -UseBasicParsing -ErrorAction Stop | Out-Null
Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force -ErrorAction Stop
$csvFiles = @(Get-ChildItem -LiteralPath $extractPath -Filter '*.csv' -File -Recurse -ErrorAction Stop)
if (-not $csvFiles.Count) { throw "Exportarchiv fuer $ReportName enthaelt keine CSV-Datei." }
$rows = New-Object 'System.Collections.Generic.List[object]'
foreach ($csvFile in $csvFiles) {
foreach ($row in @(Import-Csv -LiteralPath $csvFile.FullName -ErrorAction Stop)) { $rows.Add($row) }
}
return $rows.ToArray()
} finally {
if (Test-Path -LiteralPath $temporaryRoot) { Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Export-ModuleMember -Function Invoke-IlReportExport
} | Import-Module -Scope Local -Force
# kc-bundle:report:end
function ConvertTo-ReportNumber {
param([object]$Value)
$number = 0.0
[void][double]::TryParse([string]$Value,[Globalization.NumberStyles]::Any,[Globalization.CultureInfo]::InvariantCulture,[ref]$number)
return $number
}
Connect-IlGraph -Scopes @('DeviceManagementApps.Read.All') -TenantId $TenantId -SkipConnect:$SkipConnect
$result = New-Object 'System.Collections.Generic.List[object]'
$summaries = @(Invoke-IlReportExport -ReportName 'AppInstallStatusAggregate')
foreach ($app in $summaries) {
$failedDevices = [int](ConvertTo-ReportNumber (Get-IlProperty $app 'FailedDeviceCount'))
$failedUsers = [int](ConvertTo-ReportNumber (Get-IlProperty $app 'FailedUserCount'))
$failedPercentage = ConvertTo-ReportNumber (Get-IlProperty $app 'FailedDevicePercentage')
if (($failedDevices + $failedUsers) -le 0 -or $failedPercentage -lt $MinimumFailedDevicePercentage) { continue }
$applicationId = [string](Get-IlProperty $app 'ApplicationId')
$result.Add([pscustomobject][ordered]@{
Status='Auffaellig';Level='Summary';ApplicationId=$applicationId;DisplayName=Get-IlProperty $app 'DisplayName';AppVersion=Get-IlProperty $app 'AppVersion';Platform=Get-IlProperty $app 'Platform'
FailedDeviceCount=$failedDevices;FailedUserCount=$failedUsers;FailedDevicePercentage=$failedPercentage;DeviceId=$null;DeviceName=$null;UserPrincipalName=$null;ErrorCode=$null;InstallState=$null;Raw=$app
})
if (-not $IncludeDeviceDetails -or -not $applicationId) { continue }
$escapedApplicationId = $applicationId.Replace("'","''")
$filter = "(ApplicationId eq '" + $escapedApplicationId + "')"
foreach ($row in @(Invoke-IlReportExport -ReportName 'DeviceInstallStatusByApp' -Filter $filter)) {
$state = @((Get-IlProperty $row 'AppInstallState'),(Get-IlProperty $row 'InstallState'),(Get-IlProperty $row 'InstallStateDetail')) -join '; '
$errorCode = [string](Get-IlProperty $row 'HexErrorCode' (Get-IlProperty $row 'ErrorCode'))
if ($state -notmatch '(?i)fail|error' -and (-not $errorCode -or $errorCode -in @('0','0x0','0x00000000'))) { continue }
$result.Add([pscustomobject][ordered]@{
Status='Auffaellig';Level='Device';ApplicationId=$applicationId;DisplayName=Get-IlProperty $app 'DisplayName';AppVersion=Get-IlProperty $row 'AppVersion';Platform=Get-IlProperty $row 'Platform'
FailedDeviceCount=$null;FailedUserCount=$null;FailedDevicePercentage=$null;DeviceId=Get-IlProperty $row 'DeviceId';DeviceName=Get-IlProperty $row 'DeviceName';UserPrincipalName=Get-IlProperty $row 'UserPrincipalName';ErrorCode=$errorCode;InstallState=$state;Raw=$row
})
}
}
if (-not $result.Count) {
$result.Add((New-IlFinding -Check 'AppInstallStatusAggregate' -Status OK -Detail "Keine App erreicht die Fehlerschwelle von $MinimumFailedDevicePercentage Prozent."))
}
Export-IlResult -Data $result.ToArray() -OutputPath $OutputPath