Microsoft 365 tenant PowerShell script Report only

Get-M365ServiceHealthReport.ps1

Reports current Microsoft 365 service incidents and advisories and, optionally, Message Center changes that need action.

Overview#

Reads service health issues from /admin/serviceAnnouncement/issues and keeps the unresolved ones (or all with -IncludeResolved) that were modified in the last -DaysBack days, optionally limited to one service. Every issue is exported with its classification, status, timing, hours open, impact description and the latest post from Microsoft (HTML stripped). With -IncludeMessageCenter the Message Center posts from /admin/serviceAnnouncement/messages are exported to a second CSV named <OutputPath base>_MessageCenter.csv, with the action-required date and the days left to act. Prints active incidents by service, the number of advisories and the major changes that need action within 30 days.

Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-M365ServiceHealthReport.ps1 -Full for the complete help text.

Parameters#

ParameterWhat it does
-DaysBackOnly keep issues and messages whose lastModifiedDateTime is within the last N days. Default 7.
-ServiceWildcard filter on the service name, for example 'Microsoft Intune', 'Exchange Online' or '*Teams*'. A value without wildcards is matched as *value*. Applies to issues and Message Center posts.
-IncludeResolvedAlso keep issues that are already resolved (useful for post-incident reporting).
-IncludeMessageCenterAlso export Message Center posts. Requests the ServiceMessage.Read.All scope.
-ActionRequiredOnlyWith -IncludeMessageCenter, keep only posts that have an actionRequiredByDateTime.
-OutputPathPath of the issues CSV file. Defaults to .\Reports\M365ServiceHealth_<timestamp>.csv.
-PassThruAlso emit the issue objects (and, with -IncludeMessageCenter, the message objects) to the pipeline.

Examples#

PowerShell
PS> .\Get-M365ServiceHealthReport.ps1

Exports the unresolved incidents and advisories updated in the last 7 days and lists active incidents per service.

PowerShell
PS> .\Get-M365ServiceHealthReport.ps1 -Service 'Microsoft Intune' -DaysBack 30 -IncludeResolved -Verbose

Exports every Intune issue, resolved or not, that was updated in the last 30 days.

PowerShell
PS> .\Get-M365ServiceHealthReport.ps1 -IncludeMessageCenter -ActionRequiredOnly -DaysBack 60 -OutputPath C:\Temp\Health.csv

Also writes C:\Temp\Health_MessageCenter.csv with the Message Center posts from the last 60 days that have an action deadline.

Permissions, modules and notes#

Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication Permissions : ServiceHealth.Read.All (delegated), plus ServiceMessage.Read.All with -IncludeMessageCenter. The signed-in user needs a role that can see service health, for example Service Support Administrator or Global Reader. Notes : All date/time values are UTC. The service announcement API only returns issues and messages for services the tenant is subscribed to. Filtering is done client-side on purpose: the whole issue history is a few hundred rows and it keeps the script independent of $filter support changes on this endpoint. HoursOpen counts from startDateTime to endDateTime for resolved issues, otherwise to now.

Full source#

PowerShell · Get-M365ServiceHealthReport.ps1
<#
.SYNOPSIS
    Reports current Microsoft 365 service incidents and advisories and, optionally, Message Center changes that need action.
.DESCRIPTION
    Reads service health issues from /admin/serviceAnnouncement/issues and keeps the unresolved ones (or all
    with -IncludeResolved) that were modified in the last -DaysBack days, optionally limited to one service.
    Every issue is exported with its classification, status, timing, hours open, impact description and the
    latest post from Microsoft (HTML stripped). With -IncludeMessageCenter the Message Center posts from
    /admin/serviceAnnouncement/messages are exported to a second CSV named <OutputPath base>_MessageCenter.csv,
    with the action-required date and the days left to act. Prints active incidents by service, the number
    of advisories and the major changes that need action within 30 days.
.PARAMETER DaysBack
    Only keep issues and messages whose lastModifiedDateTime is within the last N days. Default 7.
.PARAMETER Service
    Wildcard filter on the service name, for example 'Microsoft Intune', 'Exchange Online' or '*Teams*'.
    A value without wildcards is matched as *value*. Applies to issues and Message Center posts.
.PARAMETER IncludeResolved
    Also keep issues that are already resolved (useful for post-incident reporting).
.PARAMETER IncludeMessageCenter
    Also export Message Center posts. Requests the ServiceMessage.Read.All scope.
.PARAMETER ActionRequiredOnly
    With -IncludeMessageCenter, keep only posts that have an actionRequiredByDateTime.
.PARAMETER OutputPath
    Path of the issues CSV file. Defaults to .\Reports\M365ServiceHealth_<timestamp>.csv.
.PARAMETER PassThru
    Also emit the issue objects (and, with -IncludeMessageCenter, the message objects) to the pipeline.
.EXAMPLE
    PS> .\Get-M365ServiceHealthReport.ps1
    Exports the unresolved incidents and advisories updated in the last 7 days and lists active incidents per service.
.EXAMPLE
    PS> .\Get-M365ServiceHealthReport.ps1 -Service 'Microsoft Intune' -DaysBack 30 -IncludeResolved -Verbose
    Exports every Intune issue, resolved or not, that was updated in the last 30 days.
.EXAMPLE
    PS> .\Get-M365ServiceHealthReport.ps1 -IncludeMessageCenter -ActionRequiredOnly -DaysBack 60 -OutputPath C:\Temp\Health.csv
    Also writes C:\Temp\Health_MessageCenter.csv with the Message Center posts from the last 60 days that have an action deadline.
.NOTES
    Author      : Omer Eltayeb
    Blog        : https://www.oeltayeb.com
    GitHub      : https://github.com/omer-eltayeb
    Version     : 1.0.0
    Requires    : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication
    Permissions : ServiceHealth.Read.All (delegated), plus ServiceMessage.Read.All with -IncludeMessageCenter. The signed-in
                  user needs a role that can see service health, for example Service Support Administrator or Global Reader.
    Notes       : All date/time values are UTC. The service announcement API only returns issues and messages for
                  services the tenant is subscribed to. Filtering is done client-side on purpose: the whole issue history
                  is a few hundred rows and it keeps the script independent of $filter support changes on this endpoint.
                  HoursOpen counts from startDateTime to endDateTime for resolved issues, otherwise to now.
.LINK
    https://learn.microsoft.com/graph/api/serviceannouncement-list-issues
.LINK
    https://learn.microsoft.com/graph/api/serviceannouncement-list-messages
#>
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateRange(1, 365)]
    [int]$DaysBack = 7,

    [Parameter()]
    [string]$Service,

    [Parameter()]
    [switch]$IncludeResolved,

    [Parameter()]
    [switch]$IncludeMessageCenter,

    [Parameter()]
    [switch]$ActionRequiredOnly,

    [Parameter()]
    [string]$OutputPath,

    [Parameter()]
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'

#region Helpers
function Connect-GraphIfNeeded {
    <# Connects to Microsoft Graph only when there is no usable session for the required scopes. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Scopes
    )
    $context = Get-MgContext
    $missingScopes = @()
    if ($null -ne $context) {
        $missingScopes = @($Scopes | Where-Object { $context.Scopes -notcontains $_ })
    }
    if ($null -eq $context -or $missingScopes.Count -gt 0) {
        Write-Verbose "Connecting to Microsoft Graph with scopes: $($Scopes -join ', ')"
        Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop | Out-Null
    }
    else {
        Write-Verbose "Reusing existing Microsoft Graph session for $($context.Account)."
    }
}

function Invoke-GraphPaged {
    <# GET helper that follows @odata.nextLink and returns every item in 'value'. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter()]
        [hashtable]$Headers
    )
    $results = New-Object -TypeName System.Collections.Generic.List[object]
    $nextLink = $Uri
    while (-not [string]::IsNullOrEmpty($nextLink)) {
        $requestParams = @{ Method = 'GET'; Uri = $nextLink; OutputType = 'PSObject'; ErrorAction = 'Stop' }
        if ($null -ne $Headers) { $requestParams['Headers'] = $Headers }
        $response = Invoke-MgGraphRequest @requestParams
        if ($null -ne $response.PSObject.Properties['value']) {
            foreach ($item in $response.value) { $results.Add($item) }
        }
        elseif ($null -ne $response) {
            $results.Add($response)
        }
        $nextLink = $response.'@odata.nextLink'
    }
    return $results
}

function ConvertTo-UtcDateTime {
    <# Normalises a Graph date value (ISO 8601 string or [datetime]) to a UTC [datetime]; $null when empty. #>
    param(
        [Parameter()]
        [AllowNull()]
        $Value
    )
    if ($null -eq $Value) { return $null }
    if ($Value -is [datetime]) {
        if ($Value.Kind -eq [System.DateTimeKind]::Local) { return $Value.ToUniversalTime() }
        return [datetime]::SpecifyKind($Value, [System.DateTimeKind]::Utc)
    }
    if ([string]::IsNullOrWhiteSpace([string]$Value)) { return $null }
    $parsed = [datetime]::MinValue
    $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal
    if ([datetime]::TryParse([string]$Value, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$parsed)) { return $parsed }
    return $null
}

function ConvertFrom-Html {
    <# Turns the HTML body of an announcement into single-line plain text (tags stripped, entities decoded) and truncates it. #>
    param(
        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Html,

        [Parameter()]
        [int]$MaxLength = 0
    )
    if ([string]::IsNullOrWhiteSpace($Html)) { return $null }
    # Block-level closings become spaces first so that words from adjacent paragraphs do not stick together.
    $text = $Html -replace '(?i)<br\s*/?>|</p>|</li>|</div>|</tr>|</h[1-6]>', ' '
    $text = $text -replace '<[^>]+>', ''
    $text = [System.Net.WebUtility]::HtmlDecode($text)
    $text = ($text -replace '\s+', ' ').Trim()
    if ($MaxLength -gt 3 -and $text.Length -gt $MaxLength) { $text = $text.Substring(0, $MaxLength - 3) + '...' }
    return $text
}
#endregion Helpers

#region Main
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    $reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
    $OutputPath = Join-Path -Path $reportFolder -ChildPath ('M365ServiceHealth_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
    New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}
# Path.Combine tolerates an empty folder (bare file name in -OutputPath) where Join-Path would throw.
$messagesOutputPath = [System.IO.Path]::Combine([string]$outputFolder, ('{0}_MessageCenter.csv' -f [System.IO.Path]::GetFileNameWithoutExtension($OutputPath)))

$scopes = @('ServiceHealth.Read.All')
if ($IncludeMessageCenter) { $scopes += 'ServiceMessage.Read.All' }
try {
    Connect-GraphIfNeeded -Scopes $scopes
}
catch {
    throw "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
}

$nowUtc = [datetime]::UtcNow
$since = $nowUtc.AddDays(-$DaysBack)
$servicePattern = $null
if (-not [string]::IsNullOrWhiteSpace($Service)) {
    $servicePattern = $Service
    if ($servicePattern -notmatch '[\*\?]') { $servicePattern = "*$servicePattern*" }
}

Write-Verbose 'Reading service health issues.'
try {
    $issues = @(Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/issues')
}
catch {
    throw "Failed to read service health issues: $($_.Exception.Message)"
}
Write-Verbose "Received $($issues.Count) issues; applying filters."

$issueRows = New-Object -TypeName System.Collections.Generic.List[object]
foreach ($issue in $issues) {
    $isResolved = [bool]$issue.isResolved
    if ($isResolved -and -not $IncludeResolved) { continue }
    $lastModified = ConvertTo-UtcDateTime -Value $issue.lastModifiedDateTime
    if ($null -ne $lastModified -and $lastModified -lt $since) { continue }
    if ($null -ne $servicePattern -and [string]$issue.service -notlike $servicePattern) { continue }

    $start = ConvertTo-UtcDateTime -Value $issue.startDateTime
    $end = ConvertTo-UtcDateTime -Value $issue.endDateTime
    $hoursOpen = $null
    if ($null -ne $start) {
        $openUntil = $nowUtc
        if ($isResolved -and $null -ne $end) { $openUntil = $end }
        $hoursOpen = [math]::Round(($openUntil - $start).TotalHours, 1)
    }
    $latestUpdate = $null
    $latestPost = @($issue.posts | Where-Object { $null -ne $_ }) | Sort-Object -Property createdDateTime | Select-Object -Last 1
    if ($null -ne $latestPost) { $latestUpdate = ConvertFrom-Html -Html ([string]$latestPost.description.content) -MaxLength 500 }

    $issueRows.Add([PSCustomObject]@{
            Id                   = $issue.id
            Title                = $issue.title
            Service              = $issue.service
            Feature              = $issue.feature
            Classification       = $issue.classification
            Status               = $issue.status
            Origin               = $issue.origin
            StartDateTime        = $start
            LastModifiedDateTime = $lastModified
            EndDateTime          = $end
            IsResolved           = $isResolved
            HoursOpen            = $hoursOpen
            ImpactDescription    = ConvertFrom-Html -Html ([string]$issue.impactDescription) -MaxLength 300
            LatestUpdate         = $latestUpdate
        })
}
$issueOutput = @($issueRows | Sort-Object -Property IsResolved, @{ Expression = 'LastModifiedDateTime'; Descending = $true })
if ($issueOutput.Count -gt 0) {
    $issueOutput | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
    Write-Warning 'No service health issues matched the filters; no issues CSV was written.'
}

$messageRows = New-Object -TypeName System.Collections.Generic.List[object]
if ($IncludeMessageCenter) {
    Write-Verbose 'Reading Message Center posts.'
    try {
        $messages = @(Invoke-GraphPaged -Uri 'https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages')
    }
    catch {
        throw "Failed to read Message Center posts: $($_.Exception.Message)"
    }
    Write-Verbose "Received $($messages.Count) messages; applying filters."
    foreach ($message in $messages) {
        $lastModified = ConvertTo-UtcDateTime -Value $message.lastModifiedDateTime
        if ($null -ne $lastModified -and $lastModified -lt $since) { continue }
        $actionRequiredBy = ConvertTo-UtcDateTime -Value $message.actionRequiredByDateTime
        if ($ActionRequiredOnly -and $null -eq $actionRequiredBy) { continue }
        $services = @($message.services | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        if ($null -ne $servicePattern -and @($services | Where-Object { $_ -like $servicePattern }).Count -eq 0) { continue }
        $daysUntilActionRequired = $null
        if ($null -ne $actionRequiredBy) { $daysUntilActionRequired = [int][math]::Ceiling(($actionRequiredBy - $nowUtc).TotalDays) }

        $messageRows.Add([PSCustomObject]@{
                Id                       = $message.id
                Title                    = $message.title
                Category                 = $message.category
                Severity                 = $message.severity
                Services                 = ($services -join ';')
                Tags                     = (@($message.tags | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';')
                IsMajorChange            = [bool]$message.isMajorChange
                StartDateTime            = ConvertTo-UtcDateTime -Value $message.startDateTime
                LastModifiedDateTime     = $lastModified
                ActionRequiredByDateTime = $actionRequiredBy
                DaysUntilActionRequired  = $daysUntilActionRequired
                Summary                  = ConvertFrom-Html -Html ([string]$message.body.content) -MaxLength 500
            })
    }
    # Deadlines first (soonest on top), then the rest by last update.
    $deadlineFirst = @{ Expression = { if ($null -eq $_.DaysUntilActionRequired) { [int]::MaxValue } else { $_.DaysUntilActionRequired } } }
    $messageOutput = @($messageRows | Sort-Object -Property $deadlineFirst, @{ Expression = 'LastModifiedDateTime'; Descending = $true })
    if ($messageOutput.Count -gt 0) {
        $messageOutput | Export-Csv -Path $messagesOutputPath -NoTypeInformation -Encoding UTF8
    }
    else {
        Write-Warning 'No Message Center posts matched the filters; no Message Center CSV was written.'
    }
}

$activeIncidents = @($issueOutput | Where-Object { -not $_.IsResolved -and $_.Classification -eq 'incident' })
$advisories = @($issueOutput | Where-Object { $_.Classification -eq 'advisory' })
$incidentColor = 'Green'
if ($activeIncidents.Count -gt 0) { $incidentColor = 'Red' }
Write-Host ''
Write-Host 'Service health summary' -ForegroundColor Cyan
Write-Host ('  Window / service filter      : last {0} days / {1}' -f $DaysBack, $(if ($null -eq $servicePattern) { 'all services' } else { $servicePattern }))
Write-Host ('  Issues exported              : {0} -> {1}' -f $issueOutput.Count, $OutputPath)
Write-Host ('  Active incidents             : {0}' -f $activeIncidents.Count) -ForegroundColor $incidentColor
foreach ($group in @($activeIncidents | Group-Object -Property Service | Sort-Object -Property Count -Descending)) {
    Write-Host ('    {0,-40} {1,3}' -f $group.Name, $group.Count)
}
Write-Host ('  Advisories                   : {0}' -f $advisories.Count)
if ($IncludeMessageCenter) {
    $majorChangesDue = @($messageRows | Where-Object { $_.IsMajorChange -and $null -ne $_.DaysUntilActionRequired -and $_.DaysUntilActionRequired -le 30 })
    Write-Host ('  Message Center posts         : {0} -> {1}' -f $messageRows.Count, $messagesOutputPath)
    Write-Host ('  Major changes due in 30 days : {0}' -f $majorChangesDue.Count) -ForegroundColor Yellow
    foreach ($change in ($majorChangesDue | Sort-Object -Property DaysUntilActionRequired)) {
        Write-Host ('    {0,-10} due in {1,4} days  {2}' -f $change.Id, $change.DaysUntilActionRequired, $change.Title)
    }
}

if ($PassThru) {
    $issueOutput
    if ($IncludeMessageCenter) { $messageOutput }
}
#endregion Main

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.