Alerta por correo de fallas en instalación de apps
Revisa las apps de Intune y manda un correo cuando el porcentaje de fallas de instalación pasa tu umbral, priorizando las apps requeridas. Pensado para correr diario en Azure Automation.
Ejemplo en México
Cadena de tiendas de conveniencia en Culiacán, 120 terminales punto de venta
Una actualización del software de caja falló en varias tiendas y se enteraron hasta que llamaron los encargados.
.\app-deployment-failure-alert.ps1 -FailureThresholdPercent 15 -EmailRecipients "soporte@empresa.example" -SenderUPN "alertas@empresa.example"Recibe un correo diario con las apps arriba de 15 por ciento de fallas y corrige primero las requeridas.
Cómo implementarlo
- En tu cuenta de Azure Automation habilita la identidad administrada, importa el módulo Microsoft.Graph.Authentication y crea un runbook de PowerShell con el contenido de app-deployment-failure-alert.ps1.
- Asigna a la identidad administrada los permisos de aplicación de Microsoft Graph: DeviceManagementApps.Read.All, DeviceManagementManagedDevices.Read.All y Mail.Send, y espera unos minutos a que se propaguen.
- Prueba el runbook con el panel de prueba (FailureThresholdPercent, EmailRecipients y SenderUPN) y ajusta los destinatarios.
- Programa el runbook diario y, cuando llegue una alerta, revisa el paquete y la regla de detección de la app señalada.
Ver el código completo de app-deployment-failure-alert.ps1
<#
.TITLE
App Deployment Failure Alert Notification
.SYNOPSIS
Automated runbook to monitor application deployment failures in Intune and send email alerts for deployment issues.
.DESCRIPTION
This script is designed to run as a scheduled Azure Automation runbook that monitors application
deployment status in Microsoft Intune and identifies applications with high failure rates or
deployment issues. It tracks deployment success rates, identifies required applications with
failures, and sends email notifications to administrators with detailed deployment reports.
The script helps maintain application availability and user productivity by proactively alerting
on deployment failures and providing actionable insights for remediation.
.TAGS
Notification
.MINROLE
Intune Administrator
.PERMISSIONS
DeviceManagementApps.Read.All,DeviceManagementManagedDevices.Read.All,Mail.Send
.AUTHOR
Ugur Koc
.VERSION
1.5
.CHANGELOG
1.5 - Download the app installation report payload before parsing its JSON content and add MaxApps for bounded runbook validation
1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors
1.3 - Azure Automation now records script progress, outcomes, and summaries in job history
1.2 - Mail now sends from a mandatory SenderUPN mailbox via /users/{upn}/sendMail (app-only managed identity cannot use /me); send failures now fail the run; per-app report and assignment calls are paced and the app listing uses select; pagination helper preserves single-item arrays
1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); app install status now read via deviceManagement/reports (mobileApps deviceStatuses was retired from the Graph service)
1.0 - Initial release
.LASTUPDATE
2026-07-30
.EXECUTION
RunbookOnly
.OUTPUT
Email
.SCHEDULE
Daily
.CATEGORY
Notification
.EXAMPLE
.\app-deployment-failure-alert.ps1 -FailureThresholdPercent 20 -EmailRecipients "<recipient-address>" -SenderUPN "<sender-upn>"
Alerts when app deployment failure rate exceeds 20% and sends notifications to <recipient-address>
.EXAMPLE
.\app-deployment-failure-alert.ps1 -FailureThresholdPercent 15 -EmailRecipients "<recipient-address>,<app-support-recipient-address>" -SenderUPN "<sender-upn>"
Alerts when app deployment failure rate exceeds 15% and sends notifications to multiple recipients
.NOTES
- Requires Microsoft.Graph.Authentication module
- For Azure Automation, configure Managed Identity with required permissions
- Uses Microsoft Graph Mail API for email notifications only, sent from the SenderUPN mailbox
- The Automation account's managed identity requires Mail.Send permission for the SenderUPN mailbox
- Recommended to run as scheduled runbook (daily)
- Consider your organization's application deployment requirements when setting threshold
- Review application packages and deployment settings based on findings
- Critical for maintaining application availability and user productivity
- Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, HelpMessage = "Maximum acceptable failure percentage for app deployments")]
[ValidateRange(5, 50)]
[int]$FailureThresholdPercent,
[Parameter(Mandatory = $true, HelpMessage = "Comma-separated list of email addresses to send notifications")]
[ValidateNotNullOrEmpty()]
[string]$EmailRecipients,
[Parameter(Mandatory = $true, HelpMessage = "Mailbox UPN used as the notification sender (managed identity needs Mail.Send permission for it)")]
[ValidateNotNullOrEmpty()]
[string]$SenderUPN,
[Parameter(Mandatory = $false, HelpMessage = "Maximum number of applications to evaluate; 0 evaluates all applications")]
[ValidateRange(0, 10000)]
[int]$MaxApps = 0,
[Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")]
[ValidateSet("true", "false", "1", "0", '$true', '$false')]
[string]$ForceModuleInstall
)
# Normalize the local module-install override for Azure Automation parameter binding.
$forceModuleInstallRaw = [string]$ForceModuleInstall
Remove-Variable -Name ForceModuleInstall
if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) {
$ForceModuleInstall = $false
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) {
$ForceModuleInstall = $true
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) {
$ForceModuleInstall = $false
}
else {
throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false."
}
# ============================================================================
# ENVIRONMENT DETECTION AND SETUP
# ============================================================================
function Initialize-RequiredModule {
param(
[string[]]$ModuleNames,
[bool]$IsAutomationEnvironment,
[bool]$ForceInstall = $false
)
foreach ($ModuleName in $ModuleNames) {
Write-Verbose "Checking module: $ModuleName"
$module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1
if (-not $module) {
if ($IsAutomationEnvironment) {
$errorMessage = @"
Module '$ModuleName' is not available in this Azure Automation Account.
To resolve this issue:
1. Go to Azure Portal
2. Navigate to your Automation Account
3. Go to 'Modules' > 'Browse Gallery'
4. Search for '$ModuleName'
5. Click 'Import' and wait for installation to complete
Required modules for this script:
- Microsoft.Graph.Authentication
"@
throw $errorMessage
}
else {
Write-Information "Module '$ModuleName' not found. Attempting to install..." -InformationAction Continue
if (-not $ForceInstall) {
$response = Read-Host "Install module '$ModuleName'? (Y/N)"
if ($response -notmatch '^[Yy]') {
throw "Module '$ModuleName' is required but installation was declined."
}
}
try {
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
$scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" }
Write-Information "Installing '$ModuleName' in scope '$scope'..." -InformationAction Continue
Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery
Write-Information "✓ Successfully installed '$ModuleName'" -InformationAction Continue
}
catch {
throw "Failed to install module '$ModuleName': $($_.Exception.Message)"
}
}
}
try {
Write-Verbose "Importing module: $ModuleName"
Import-Module -Name $ModuleName -Force -ErrorAction Stop
Write-Verbose "✓ Successfully imported '$ModuleName'"
}
catch {
throw "Failed to import module '$ModuleName': $($_.Exception.Message)"
}
}
}
# Detect execution environment
if ($PSPrivateMetadata.JobId.Guid) {
Write-Output "Running inside Azure Automation Runbook"
$IsAzureAutomation = $true
}
else {
Write-Output "Running locally in IDE or terminal"
$IsAzureAutomation = $false
}
# Initialize required modules
$RequiredModuleList = @(
"Microsoft.Graph.Authentication"
)
# MgGraphCommunity gives WAM-free interactive sign-in for local runs
if (-not $IsAzureAutomation) {
$RequiredModuleList += "MgGraphCommunity"
}
try {
Initialize-RequiredModule -ModuleNames $RequiredModuleList -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall
Write-Verbose "✓ All required modules are available"
}
catch {
Write-Error "Module initialization failed: $_"
exit 1
}
# ============================================================================
# AUTHENTICATION
# ============================================================================
try {
if ($IsAzureAutomation) {
Write-Output "Connecting to Microsoft Graph using Managed Identity..."
Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop
Write-Output "✓ Successfully connected to Microsoft Graph using Managed Identity"
}
else {
Write-Output "Connecting to Microsoft Graph with interactive authentication..."
$Scopes = @(
"DeviceManagementApps.Read.All",
"DeviceManagementManagedDevices.Read.All",
"Mail.Send"
)
Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome -ErrorAction Stop
Write-Output "✓ Successfully connected to Microsoft Graph"
}
}
catch {
Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
exit 1
}
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
function Get-MgGraphAllPage {
param(
[Parameter(Mandatory = $true)]
[string]$Uri,
[int]$DelayMs = 100
)
$AllResults = @()
$NextLink = $Uri
$RequestCount = 0
do {
try {
if ($RequestCount -gt 0) {
Start-Sleep -Milliseconds $DelayMs
}
$Response = Invoke-MgGraphRequest -Uri $NextLink -Method GET
$RequestCount++
if ($null -ne $Response.value) {
$AllResults += $Response.value
}
else {
$AllResults += $Response
}
$NextLink = $Response.'@odata.nextLink'
}
catch {
if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") {
Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue
Start-Sleep -Seconds 60
continue
}
throw "Error fetching data from $NextLink : $($_.Exception.Message)"
}
} while ($NextLink)
# Comma keeps PowerShell from unrolling a single-item array on return
return , $AllResults
}
# Retrieves app installation status rows from the reports endpoint with paging
# (the mobileApps deviceStatuses endpoint was retired from the Graph service)
function Get-AppInstallStatusReportRow {
param(
[Parameter(Mandatory = $true)]
[string]$AppId,
[int]$PageSize = 500,
[int]$DelayMs = 100
)
$ReportUri = "https://graph.microsoft.com/beta/deviceManagement/reports/retrieveDeviceAppInstallationStatusReport"
$AllRows = @()
$Skip = 0
# MaxValue sentinel keeps the loop alive if the very first page hits a 429
# (a zero sentinel would end the do/while before any retry could happen)
$TotalRows = [int]::MaxValue
do {
try {
if ($Skip -gt 0) {
Start-Sleep -Milliseconds $DelayMs
}
$Body = @{
filter = "(ApplicationId eq '$AppId')"
top = $PageSize
skip = $Skip
select = @("DeviceId", "DeviceName", "UserPrincipalName", "Platform", "AppVersion", "InstallState", "InstallStateDetail", "ErrorCode", "LastModifiedDateTime")
} | ConvertTo-Json -Depth 5
$ReportPath = Join-Path ([System.IO.Path]::GetTempPath()) "IntuneAutomation-AppDeploymentAlert-$([guid]::NewGuid().ToString('N')).json"
try {
Invoke-MgGraphRequest -Uri $ReportUri -Method POST -Body $Body -ContentType "application/json" -OutputFilePath $ReportPath | Out-Null
$Response = Get-Content -LiteralPath $ReportPath -Raw | ConvertFrom-Json -AsHashtable
}
finally {
if (Test-Path -LiteralPath $ReportPath) {
Remove-Item -LiteralPath $ReportPath -Force
}
}
# The report returns columnar JSON - map Schema columns to row indexes,
# column order is declared by Schema, not by the request
$ColumnIndex = @{}
for ($i = 0; $i -lt $Response['Schema'].Count; $i++) {
$ColumnIndex[$Response['Schema'][$i].Column] = $i
}
foreach ($Row in $Response['Values']) {
$AllRows += [PSCustomObject]@{
DeviceId = $Row[$ColumnIndex['DeviceId']]
DeviceName = $Row[$ColumnIndex['DeviceName']]
UserPrincipalName = $Row[$ColumnIndex['UserPrincipalName']]
Platform = $Row[$ColumnIndex['Platform']]
AppVersion = $Row[$ColumnIndex['AppVersion']]
InstallState = $Row[$ColumnIndex['InstallState']]
InstallStateDetail = $Row[$ColumnIndex['InstallStateDetail']]
ErrorCode = $Row[$ColumnIndex['ErrorCode']]
LastModifiedDateTime = $Row[$ColumnIndex['LastModifiedDateTime']]
}
}
$TotalRows = $Response['TotalRowCount']
$Skip += $PageSize
}
catch {
if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") {
Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue
Start-Sleep -Seconds 60
continue
}
throw "Error fetching install status report for app $AppId : $($_.Exception.Message)"
}
} while ($Skip -lt $TotalRows)
# Comma preserves a single-row result as an array so .Count is correct
return , $AllRows
}
function Get-AppType {
param([string]$ODataType)
switch ($ODataType) {
"#microsoft.graph.win32LobApp" { return "Win32 App" }
"#microsoft.graph.microsoftStoreForBusinessApp" { return "Store App" }
"#microsoft.graph.webApp" { return "Web App" }
"#microsoft.graph.officeSuiteApp" { return "Office Suite" }
"#microsoft.graph.winGetApp" { return "WinGet App" }
"#microsoft.graph.iosLobApp" { return "iOS LOB App" }
"#microsoft.graph.iosStoreApp" { return "iOS Store App" }
"#microsoft.graph.androidManagedStoreApp" { return "Android Store App" }
"#microsoft.graph.androidLobApp" { return "Android LOB App" }
"#microsoft.graph.macOSLobApp" { return "macOS LOB App" }
"#microsoft.graph.macOSOfficeSuiteApp" { return "macOS Office Suite" }
default { return "Other" }
}
}
function Get-InstallIntentDisplay {
param([string]$Intent)
switch ($Intent) {
"required" { return "Required" }
"available" { return "Available" }
"uninstall" { return "Uninstall" }
"availableWithoutEnrollment" { return "Available (No Enrollment)" }
default { return $Intent }
}
}
function Get-InstallStateDisplay {
param([string]$InstallState)
switch ($InstallState) {
"installed" { return "Installed" }
"failed" { return "Failed" }
"notInstalled" { return "Not Installed" }
"uninstallFailed" { return "Uninstall Failed" }
"pendingInstall" { return "Pending Install" }
"unknown" { return "Unknown" }
"notApplicable" { return "Not Applicable" }
default { return $InstallState }
}
}
function Get-DeploymentSeverity {
param([string]$InstallState, [string]$Intent)
if ($Intent -eq "required") {
switch ($InstallState) {
"failed" { return "Critical" }
"uninstallFailed" { return "Critical" }
"pendingInstall" { return "Warning" }
"notInstalled" { return "Warning" }
"installed" { return "Success" }
default { return "Info" }
}
}
else {
switch ($InstallState) {
"failed" { return "Warning" }
"uninstallFailed" { return "Warning" }
"installed" { return "Success" }
default { return "Info" }
}
}
}
function Send-EmailNotification {
[CmdletBinding(SupportsShouldProcess)]
param(
[string[]]$Recipients,
[string]$Subject,
[string]$Body
)
try {
foreach ($Recipient in $Recipients) {
$Message = @{
subject = $Subject
body = @{
contentType = "HTML"
content = $Body
}
toRecipients = @(
@{
emailAddress = @{
address = $Recipient
}
}
)
}
$RequestBody = @{
message = $Message
} | ConvertTo-Json -Depth 10
if ($PSCmdlet.ShouldProcess($Recipient, "Send Email Notification")) {
$Uri = "https://graph.microsoft.com/beta/users/$SenderUPN/sendMail"
Invoke-MgGraphRequest -Uri $Uri -Method POST -Body $RequestBody -ContentType "application/json" | Out-Null
Write-Information "✓ Email sent to $Recipient via Microsoft Graph" -InformationAction Continue
}
}
return $true
}
catch {
Write-Error "Failed to send email notification: $($_.Exception.Message)"
return $false
}
}
function New-EmailBody {
param(
[array]$AllApps,
[array]$FailedApps,
[array]$RequiredFailedApps,
[hashtable]$AppStats,
[int]$FailureThreshold
)
$TotalApps = $AllApps.Count
$AppsWithFailures = ($AllApps | Where-Object { $_.FailureCount -gt 0 }).Count
$OverallFailureRate = if ($AppStats.TotalDeployments -gt 0) {
[math]::Round(($AppStats.TotalFailures / $AppStats.TotalDeployments) * 100, 1)
}
else { 0 }
$AppTypeSummary = $FailedApps | Group-Object AppType | Sort-Object Count -Descending
$PlatformSummary = $FailedApps | Group-Object TargetPlatform | Sort-Object Count -Descending
$Body = @"
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; }
.header { background-color: #0078d4; color: white; padding: 15px; border-radius: 5px; }
.summary { background-color: #f8f9fa; padding: 15px; margin: 15px 0; border-radius: 5px; border-left: 4px solid #0078d4; }
.critical { background-color: #fdf2f2; border-left: 4px solid #dc3545; padding: 10px; margin: 10px 0; }
.warning { background-color: #fffbf0; border-left: 4px solid #ffc107; padding: 10px; margin: 10px 0; }
.success { background-color: #e8f5e8; border-left: 4px solid #28a745; padding: 10px; margin: 10px 0; }
.info { background-color: #e7f3ff; border-left: 4px solid #17a2b8; padding: 10px; margin: 10px 0; }
.app-item { margin: 5px 0; padding: 8px; background-color: white; border-radius: 3px; font-size: 14px; }
.category-summary { margin: 10px 0; padding: 8px; background-color: #f0f0f0; border-radius: 3px; }
.footer { margin-top: 30px; font-size: 12px; color: #666; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 15px 0; }
.stat-card { background-color: white; padding: 15px; border-radius: 5px; text-align: center; border: 1px solid #ddd; }
.failure-meter { width: 100%; height: 20px; background-color: #e0e0e0; border-radius: 10px; margin: 10px 0; position: relative; }
.failure-fill { height: 100%; border-radius: 10px; transition: width 0.3s ease; }
.failure-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: bold; color: white; text-shadow: 1px 1px 1px rgba(0,0,0,0.5); }
.progress-bar { width: 100%; height: 8px; background-color: #e0e0e0; border-radius: 4px; margin: 5px 0; }
.progress-fill { height: 100%; border-radius: 4px; }
h2 { color: #333; }
h3 { color: #555; margin-top: 20px; }
.status-icon { font-size: 16px; margin-right: 5px; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.center { text-align: center; }
.right { text-align: right; }
</style>
</head>
<body>
<div class="header">
<h1>📱 App Deployment Failure Alert</h1>
<p>Failure threshold: $FailureThreshold% | Current overall rate: $OverallFailureRate%</p>
</div>
<div class="summary">
<h2>Application Deployment Overview</h2>
<div class="failure-meter">
<div class="failure-fill" style="width: $(if ($OverallFailureRate -gt 100) { 100 } else { $OverallFailureRate })%; background-color: $(if ($OverallFailureRate -le $FailureThreshold) { '#28a745' } elseif ($OverallFailureRate -le ($FailureThreshold * 1.5)) { '#ffc107' } else { '#dc3545' });"></div>
<div class="failure-text">$OverallFailureRate%</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3 style="margin: 0; color: #0078d4;">$TotalApps</h3>
<p style="margin: 5px 0;">Total Applications</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #dc3545;">$AppsWithFailures</h3>
<p style="margin: 5px 0;">Apps with Failures</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #ffc107;">$($RequiredFailedApps.Count)</h3>
<p style="margin: 5px 0;">Required Apps Failing</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3 style="margin: 0; color: #17a2b8;">$($AppStats.TotalDeployments)</h3>
<p style="margin: 5px 0;">Total Deployments</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #28a745;">$($AppStats.TotalSuccesses)</h3>
<p style="margin: 5px 0;">Successful Installs</p>
</div>
<div class="stat-card">
<h3 style="margin: 0; color: #dc3545;">$($AppStats.TotalFailures)</h3>
<p style="margin: 5px 0;">Failed Installs</p>
</div>
</div>
</div>
"@
if ($RequiredFailedApps.Count -gt 0) {
$Body += @"
<div class="critical">
<h3><span class="status-icon">🚨</span>Required Applications with Failures - Critical Impact ($($RequiredFailedApps.Count) apps)</h3>
<p>These required applications are failing to install and may impact user productivity:</p>
<table>
<tr>
<th>Application Name</th>
<th>App Type</th>
<th>Platform</th>
<th>Total Deployments</th>
<th>Failures</th>
<th>Failure Rate</th>
<th>Success Rate</th>
</tr>
"@
foreach ($App in ($RequiredFailedApps | Sort-Object FailureRate -Descending | Select-Object -First 15)) {
$SuccessRate = [math]::Round((($App.TotalDeployments - $App.FailureCount) / $App.TotalDeployments) * 100, 1)
$Body += @"
<tr>
<td>$($App.AppName)</td>
<td>$($App.AppType)</td>
<td>$($App.TargetPlatform)</td>
<td class="center">$($App.TotalDeployments)</td>
<td class="center">$($App.FailureCount)</td>
<td class="center" style="color: #dc3545; font-weight: bold;">$($App.FailureRate)%</td>
<td class="center">
<div class="progress-bar">
<div class="progress-fill" style="width: $SuccessRate%; background-color: $(if ($SuccessRate -ge 90) { '#28a745' } elseif ($SuccessRate -ge 70) { '#ffc107' } else { '#dc3545' });"></div>
</div>
$SuccessRate%
</td>
</tr>
"@
}
if ($RequiredFailedApps.Count -gt 15) {
$Body += @"
<tr>
<td colspan="7" class="center"><em>... and $($RequiredFailedApps.Count - 15) more required applications with failures</em></td>
</tr>
"@
}
$Body += "</table></div>"
}
if ($FailedApps.Count -gt $RequiredFailedApps.Count) {
$AvailableFailedApps = $FailedApps | Where-Object { $_.InstallIntent -ne "Required" }
$Body += @"
<div class="warning">
<h3><span class="status-icon">⚠️</span>Available Applications with Failures ($($AvailableFailedApps.Count) apps)</h3>
<p>These available applications have deployment failures that may affect user experience:</p>
<table>
<tr>
<th>Application Name</th>
<th>App Type</th>
<th>Install Intent</th>
<th>Platform</th>
<th>Failures</th>
<th>Failure Rate</th>
</tr>
"@
foreach ($App in ($AvailableFailedApps | Sort-Object FailureRate -Descending | Select-Object -First 10)) {
$Body += @"
<tr>
<td>$($App.AppName)</td>
<td>$($App.AppType)</td>
<td>$($App.InstallIntentDisplay)</td>
<td>$($App.TargetPlatform)</td>
<td class="center">$($App.FailureCount)</td>
<td class="center" style="color: #ffc107; font-weight: bold;">$($App.FailureRate)%</td>
</tr>
"@
}
if ($AvailableFailedApps.Count -gt 10) {
$Body += @"
<tr>
<td colspan="6" class="center"><em>... and $($AvailableFailedApps.Count - 10) more available applications with failures</em></td>
</tr>
"@
}
$Body += "</table></div>"
}
if ($AppTypeSummary.Count -gt 0) {
$Body += @"
<div class="info">
<h3><span class="status-icon">📊</span>Failure Analysis by App Type</h3>
"@
foreach ($AppType in $AppTypeSummary) {
$Body += @"
<div class="category-summary">
<strong>$($AppType.Name):</strong> $($AppType.Count) applications with failures
</div>
"@
}
$Body += "</div>"
}
if ($PlatformSummary.Count -gt 0) {
$Body += @"
<div class="info">
<h3><span class="status-icon">🖥️</span>Failure Analysis by Platform</h3>
"@
foreach ($Platform in $PlatformSummary) {
$Body += @"
<div class="category-summary">
<strong>$($Platform.Name):</strong> $($Platform.Count) applications with failures
</div>
"@
}
$Body += "</div>"
}
$Body += @"
<div class="$(if ($OverallFailureRate -le $FailureThreshold) { 'success' } else { 'critical' })">
<h3><span class="status-icon">💡</span>Deployment Improvement Recommendations</h3>
<h4>Immediate Actions:</h4>
<ul>
<li><strong>Prioritize Required Apps:</strong> Focus on fixing required applications first as they have the highest business impact</li>
<li><strong>Review App Packages:</strong> Check application packages for corruption or compatibility issues</li>
<li><strong>Validate Dependencies:</strong> Ensure all application dependencies are properly installed</li>
<li><strong>Check Target Requirements:</strong> Verify device requirements match application specifications</li>
</ul>
<h4>Technical Investigation:</h4>
<ul>
<li><strong>Review Install Logs:</strong> Examine detailed installation logs for root cause analysis</li>
<li><strong>Test Deployment Groups:</strong> Validate deployments with smaller test groups first</li>
<li><strong>Network Connectivity:</strong> Ensure devices have reliable connectivity for large app downloads</li>
<li><strong>Storage Space:</strong> Verify target devices have sufficient storage for installations</li>
</ul>
<h4>Process Improvements:</h4>
<ul>
<li><strong>Staging Deployments:</strong> Implement phased rollouts for better failure detection</li>
<li><strong>Monitoring Enhancement:</strong> Set up proactive monitoring for deployment status</li>
<li><strong>User Communication:</strong> Inform users about application issues and expected resolution times</li>
<li><strong>Rollback Plans:</strong> Prepare rollback procedures for problematic deployments</li>
</ul>
</div>
<div class="footer">
<p><strong>Next Steps:</strong></p>
<ol>
<li>Investigate required applications with highest failure rates first</li>
<li>Use the existing application reports to get detailed deployment status</li>
<li>Review application packages and deployment settings for problematic apps</li>
<li>Consider implementing staged deployments for better failure management</li>
</ol>
<p><em>This is an automated notification from your Intune monitoring system.</em></p>
<p><em>Generated on: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC')</em></p>
</div>
</body>
</html>
"@
return $Body
}
# ============================================================================
# MAIN SCRIPT LOGIC
# ============================================================================
try {
Write-Output "Starting app deployment failure monitoring..."
# Parse email recipients
$EmailRecipientList = $EmailRecipients -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }
if ($EmailRecipientList.Count -eq 0) {
throw "No valid email recipients provided"
}
Write-Output "Email recipients: $($EmailRecipientList -join ', ')"
Write-Output "Failure threshold: $FailureThresholdPercent%"
# Initialize results arrays
$AllApps = @()
$FailedApps = @()
$RequiredFailedApps = @()
$NotificationFailed = $false
$AppProcessingErrors = 0
# Initialize statistics
$AppStats = @{
TotalDeployments = 0
TotalSuccesses = 0
TotalFailures = 0
TotalPending = 0
}
# ========================================================================
# GET ALL MOBILE APPLICATIONS
# ========================================================================
Write-Output "Retrieving mobile applications..."
try {
$AppsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName,publisher,createdDateTime,lastModifiedDateTime"
$Apps = Get-MgGraphAllPage -Uri $AppsUri
if ($MaxApps -gt 0) {
$Apps = @($Apps | Select-Object -First $MaxApps)
}
Write-Output "Found $($Apps.Count) applications"
foreach ($App in $Apps) {
try {
# Skip apps without essential information
if (-not $App.id -or -not $App.displayName) {
Write-Verbose "Skipping app with missing essential data"
continue
}
Write-Verbose "Processing app: $($App.displayName)"
# Get app install status for this application via the reports endpoint
$InstallStatuses = Get-AppInstallStatusReportRow -AppId $App.id
# Get app assignments to determine install intent
$AppAssignmentsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($App.id)/assignments"
$Assignments = Get-MgGraphAllPage -Uri $AppAssignmentsUri
# Determine primary install intent (prioritize required)
$InstallIntent = "available"
$TargetPlatform = "Unknown"
if ($Assignments) {
foreach ($Assignment in $Assignments) {
if ($Assignment.intent -eq "required") {
$InstallIntent = "required"
break
}
elseif ($Assignment.intent -eq "available" -and $InstallIntent -ne "required") {
$InstallIntent = "available"
}
}
}
# Determine target platform from app type
switch -Regex ($App.'@odata.type') {
"win32|office" { $TargetPlatform = "Windows" }
"ios" { $TargetPlatform = "iOS" }
"android" { $TargetPlatform = "Android" }
"macOS" { $TargetPlatform = "macOS" }
"web" { $TargetPlatform = "Web" }
default { $TargetPlatform = "Cross-Platform" }
}
# Calculate deployment statistics
# InstallState values per the resultantAppState enum (Microsoft Graph beta):
# 1 installed, 2 failed, 3 notInstalled, 4 uninstallFailed, 5 pendingInstall
$TotalDeployments = $InstallStatuses.Count
$SuccessfulInstalls = ($InstallStatuses | Where-Object { $_.InstallState -eq 1 }).Count
$FailedInstalls = ($InstallStatuses | Where-Object { $_.InstallState -in @(2, 4) }).Count
$PendingInstalls = ($InstallStatuses | Where-Object { $_.InstallState -eq 5 }).Count
$FailureRate = if ($TotalDeployments -gt 0) {
[math]::Round(($FailedInstalls / $TotalDeployments) * 100, 1)
}
else { 0 }
$AppType = Get-AppType -ODataType $App.'@odata.type'
$InstallIntentDisplay = Get-InstallIntentDisplay -Intent $InstallIntent
$AppInfo = [PSCustomObject]@{
AppId = $App.id
AppName = $App.displayName
AppType = $AppType
TargetPlatform = $TargetPlatform
InstallIntent = $InstallIntent
InstallIntentDisplay = $InstallIntentDisplay
Publisher = $App.publisher
TotalDeployments = $TotalDeployments
SuccessfulInstalls = $SuccessfulInstalls
FailedInstalls = $FailedInstalls
FailureCount = $FailedInstalls
PendingInstalls = $PendingInstalls
FailureRate = $FailureRate
CreatedDateTime = if ($App.createdDateTime) { [datetime]$App.createdDateTime } else { $null }
LastModifiedDateTime = if ($App.lastModifiedDateTime) { [datetime]$App.lastModifiedDateTime } else { $null }
}
# Update overall statistics
$AppStats.TotalDeployments += $TotalDeployments
$AppStats.TotalSuccesses += $SuccessfulInstalls
$AppStats.TotalFailures += $FailedInstalls
$AppStats.TotalPending += $PendingInstalls
$AllApps += $AppInfo
# Categorize apps with failures
if ($FailedInstalls -gt 0 -and $FailureRate -gt $FailureThresholdPercent) {
$FailedApps += $AppInfo
if ($InstallIntent -eq "required") {
$RequiredFailedApps += $AppInfo
}
}
# Pace per-app report and assignment calls to avoid throttling
Start-Sleep -Milliseconds 200
}
catch {
$AppProcessingErrors++
Write-Warning "Error processing app '$($App.displayName)' (ID: $($App.id)): $($_.Exception.Message)"
continue
}
}
if ($AppProcessingErrors -gt 0) {
throw "The report could not fully evaluate $AppProcessingErrors application(s). No healthy result will be reported from partial data."
}
Write-Output "✓ Processed $($AllApps.Count) applications successfully"
Write-Output " • Applications with failures: $($FailedApps.Count)"
Write-Output " • Required apps with failures: $($RequiredFailedApps.Count)"
}
catch {
Write-Error "Failed to retrieve mobile applications: $($_.Exception.Message)"
exit 1
}
# ========================================================================
# CALCULATE OVERALL STATISTICS
# ========================================================================
$OverallFailureRate = if ($AppStats.TotalDeployments -gt 0) {
[math]::Round(($AppStats.TotalFailures / $AppStats.TotalDeployments) * 100, 1)
}
else { 0 }
Write-Output " • Total deployments: $($AppStats.TotalDeployments)"
Write-Output " • Successful installs: $($AppStats.TotalSuccesses)"
Write-Output " • Failed installs: $($AppStats.TotalFailures)"
Write-Output " • Overall failure rate: $OverallFailureRate%"
# ========================================================================
# SEND NOTIFICATIONS IF DEPLOYMENT FAILURES DETECTED
# ========================================================================
$RequiresNotification = ($OverallFailureRate -gt $FailureThresholdPercent) -or
($RequiredFailedApps.Count -gt 0) -or
($FailedApps.Count -gt 0)
if ($RequiresNotification) {
Write-Output "Preparing email notification for app deployment failures..."
$Subject = if ($RequiredFailedApps.Count -gt 0) {
"[Intune Alert] CRITICAL: $($RequiredFailedApps.Count) Required App(s) Failing to Deploy"
}
elseif ($OverallFailureRate -gt $FailureThresholdPercent) {
"[Intune Alert] APP DEPLOYMENT ISSUES: $OverallFailureRate% Failure Rate (Threshold: $FailureThresholdPercent%)"
}
else {
"[Intune Alert] APPLICATION MONITORING: $($FailedApps.Count) App(s) with Deployment Failures"
}
$EmailBody = New-EmailBody -AllApps $AllApps -FailedApps $FailedApps -RequiredFailedApps $RequiredFailedApps -AppStats $AppStats -FailureThreshold $FailureThresholdPercent
$EmailSent = Send-EmailNotification -Recipients $EmailRecipientList -Subject $Subject -Body $EmailBody
if ($EmailSent) {
Write-Output "✓ Email notification sent to $($EmailRecipientList.Count) recipients"
}
else {
Write-Warning "Email notification could not be delivered to all recipients"
$NotificationFailed = $true
}
}
else {
Write-Output "✓ No significant app deployment failures detected. All applications are deploying successfully."
}
# ========================================================================
# DISPLAY SUMMARY
# ========================================================================
Write-Output "`n📱 APP DEPLOYMENT FAILURE MONITORING SUMMARY"
Write-Output "============================================="
Write-Output "Total Applications: $($AllApps.Count)"
Write-Output "Failure Threshold: $FailureThresholdPercent%"
Write-Output "Overall Failure Rate: $OverallFailureRate%"
Write-Output ""
Write-Output "Deployment Statistics:"
Write-Output " • Total Deployments: $($AppStats.TotalDeployments)"
Write-Output " • Successful: $($AppStats.TotalSuccesses)"
Write-Output " • Failed: $($AppStats.TotalFailures)"
Write-Output " • Pending: $($AppStats.TotalPending)"
Write-Output ""
Write-Output "Applications with Issues:"
Write-Output " • Apps with failures: $($FailedApps.Count)"
Write-Output " • Required apps failing: $($RequiredFailedApps.Count)"
Write-Output ""
if ($RequiredFailedApps.Count -gt 0) {
Write-Output "Top Required Apps with Failures:"
$TopRequiredFailed = $RequiredFailedApps | Sort-Object FailureRate -Descending | Select-Object -First 5
foreach ($App in $TopRequiredFailed) {
Write-Output " 🔸 $($App.AppName) ($($App.AppType)) - $($App.FailureRate)% failure rate"
}
Write-Output ""
}
$StatusIcon = if ($OverallFailureRate -le $FailureThresholdPercent -and $RequiredFailedApps.Count -eq 0) { "✅" } else { "⚠️" }
Write-Output "$StatusIcon Deployment Status: $(if ($OverallFailureRate -le $FailureThresholdPercent -and $RequiredFailedApps.Count -eq 0) { 'HEALTHY' } else { 'ISSUES DETECTED' })"
Write-Output "`n✓ App deployment failure monitoring completed successfully"
}
catch {
Write-Error "Script failed: $($_.Exception.Message)"
exit 1
}
finally {
try {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
Write-Output "Disconnected from Microsoft Graph"
}
catch {
# Silently ignore disconnect errors as they're not critical
Write-Verbose "Disconnect error (ignored): $($_.Exception.Message)"
}
}
# ============================================================================
# SCRIPT SUMMARY
# ============================================================================
Write-Output "
========================================
Script Execution Summary
========================================
Script: App Deployment Failure Alert
Total Applications Analyzed: $($AllApps.Count)
Overall Failure Rate: $OverallFailureRate%
Failure Threshold: $FailureThresholdPercent%
Total Deployments: $($AppStats.TotalDeployments)
Failed Deployments: $($AppStats.TotalFailures)
Apps with Failures: $($FailedApps.Count)
Required Apps Failing: $($RequiredFailedApps.Count)
Email Recipients: $($EmailRecipientList.Count)
Status: Completed
========================================
"
# Fail the run if notification delivery failed
if ($NotificationFailed) {
exit 1
}
# Copia sin cambios de https://github.com/ugurkocde/IntuneAutomation (commit 797bb2e).
# MIT License
#
# Copyright (c) 2025 Ugur Koc | Microsoft MVP
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.