Surprisingly Microsoft does not appear to alert when you are running low or out if licenses in M365. At least, not as far as I can tell. There is also no historical data in M365 around license usage which could be very useful in budget planning and forecasting.
I dug through PRTGs built-in sensors and could not find a way to get it to talk to M365’s Graph API.
What I settled on was a PowerShell script that is run on our scripting server every hour that gathers our licensing information, formats the data in PRTG Custom Sensor XML and writes the output to a file on one of our web servers. I then created a ‘HTTP Data Advanced’ sensor in PRTG that pointed at the XML file on the web server.
Now PRTG can collect data from that XML file, log it and alert based on thresholds.
Figured this might be helpful for someone else so here is the script:
<# .SYNOPSIS Script connects to Microsoft Graph and grabs subscription usage information for a given license and output it in PRTGs XML format. .DESCRIPTION Graph Scopes required: Organization.Read.All PowerShell modules required: Microsoft.Graph The XML file is output to a directory on a Web Server and then ingested by PRTG. This seemed like the easiest way to get the data into PRTG since it can't connect to Graph directly and my attempts to setup a special service account to do it did not work out. The XML file has default limits configured in for PRTG to know when to alert us to problems. They are: - Error when we have 0 licenses left - Warning when we have 1% of the total licenses left PRTG does not re-read this limit information so it is only applicable the first time you setup the sensor. Every time after that we have to manually adjust the limits in PRTG if we add/remove licenses. .EXAMPLE # Default method to run this script, outputs only the PRTG XML ./Get-M365LicenseUsage.ps1 .EXAMPLE # Useful for debugging, no files are changed ./Get-M365LicenseUsage.ps1 -dryrun .EXAMPLE # In case you don't have access to the certificate or want to manually authenticate to graph ./Get-M365LicenseUsage.ps1 -interactive .NOTES #> param ( [Parameter(Mandatory=$false)] [switch]$dryrun, [Parameter(Mandatory=$false)] [switch]$interactive ) # Certificate Authentication Information for Graph $appID = '<M365 App ID>' $tenantID = '<M365 Tenant ID>' $thumbprint = '<Certificate Thumbprint for non-interactive login to Graph>' $cert = Get-ChildItem Cert:\LocalMachine\My\$thumbprint # Web Directory Location for XML output. PRTG will then read this file. # This script is meant to be run locally where the IIS Site is hosted. # This could easily be replaced with a UNC instead. $xmlOutputDirectory = "E:\IIS-Sites\my.internal.site\M365-License-Information" $xmlOutputFileName = "M365-License-Information.xml" # Setup logging, retention time, verify directory for logs exists and create it if it doesn't $logDirFullPath = "$($PSScriptRoot)\Logs" $logfileName = "Get-M365LicenseUsage-$(Get-Date -Format "yyyyMMdd").log" $logDirExists = Test-Path -Path $logDirFullPath $logRetentionDays = "60" if ($false -eq $logDirExists) { try { New-Item -Path $logDirFullPath -ItemType Directory } catch { Write-Host -ForegroundColor Red "$(Get-Date -Format "yyyy-MM-dd H:mm:ss") - Something went wrong creating the log directoy. Terminating script." Exit } } # Start transcript Start-Transcript -Path "$($logDirFullPath)\$logfileName" -Append # Cleanup log files older than retention time $dateToDelete = (Get-Date).AddDays(-$logRetentionDays) Get-ChildItem "$($logDirFullPath)\*.log" | Where-Object { $_.LastWriteTime -lt $dateToDelete } | Remove-Item # Check and see if the directory exists if (-not (Test-Path $xmlOutputDirectory)) { if ($dryrun) { Write-Host -ForegroundColor Cyan "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - DRYRUN: Directory $xmlOutputDirectory does not exist, creating it" } else { Write-Host -ForegroundColor Yellow "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - INFO: Directory $xmlOutputDirectory does not exist, creating it" New-Item -Path $xmlOutputDirectory -ItemType Directory } } # An array of custom objects that contain a license Id and an associated friendly name # To find these values do the following: # 1. Go to https://admin.microsoft.com/ and login with your admin account # 2. Go to the 'Billing' section # 3. Click on 'Licenses' # 4. Click on the license you want to monitor # 5. The URL will look like this: https://admin.microsoft.com/Adminportal/Home#/licensedetailpage/<GUID> # 6. Take the GUID at the end of the URL, prefix it with "<TENANT ID>_" and add it to the LicenseIds array $licenseIds = @( [PSCustomObject]@{ LicenseId = "<TENANT ID>_<GUID>"; FriendlyName = "A5 Faculty" }, [PSCustomObject]@{ LicenseId = "<TENANT ID>_<GUID>"; FriendlyName = "A1 Faculty" }, [PSCustomObject]@{ LicenseId = "<TENANT ID>_<GUID>"; FriendlyName = "A5 Student" } ) # First element in the XML output (https://www.paessler.com/manuals/prtg/custom_sensors) $prtgXMLOutout = "<?xml version=`"1.0`" encoding=`"UTF-8`" ?> <!-- Generated $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") --> <prtg>`n" Write-Host -ForegroundColor Yellow "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - INFO: Connecting to Microsoft Graph" if ($interactive) { Connect-MgGraph -Scopes "Organization.Read.All" -NoWelcome } else { # Connect to Microsoft Graph with the required permissions Connect-MgGraph -ClientId $appID -TenantId $tenantID -Certificate $cert -NoWelcome } Write-Host -ForegroundColor Yellow "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - INFO: Iterating through license IDs" foreach ($license in $licenseIds) { Write-Host -ForegroundColor Yellow "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - INFO: Checking $($license.FriendlyName) ($($license.LicenseId)) license usage" # Get the license information for the given license ID $licenseInfo = Get-MgSubscribedSku -SubscribedSkuId $license.LicenseId | Select-Object ConsumedUnits -ExpandProperty PrepaidUnits Write-Host -ForegroundColor Yellow "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - INFO: $($license.FriendlyName) ($($license.LicenseId)) usage is $($licenseInfo.ConsumedUnits), appending to XML output for PRTG" # Append the license usage information to the PRTG XML output # <LimitMaxWarning> is calculated as the total licenses minus 1% of the total licenses rounded up $prtgXMLOutout += " <result> <channel>$($license.FriendlyName)</channel> <value>$($licenseInfo.ConsumedUnits)</value> <LimitMode>1</LimitMode> <LimitMaxError>$($licenseInfo.Enabled)</LimitMaxError> <LimitMaxWarning>$($licenseInfo.Enabled - [math]::Ceiling($licenseInfo.Enabled * 0.01))</LimitMaxWarning> <Unit>Custom</Unit> <CustomUnit>Licenses</CustomUnit> <Mode>Absolute</Mode> </result>`n" } # Last element in the XML output $prtgXMLOutout += "</prtg>" Write-Host -ForegroundColor Yellow "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - INFO: Disconnecting from Microsoft Graph" # Disconnect from Microsoft Graph Disconnect-MgGraph | Out-Null if ($dryrun) { Write-Host -ForegroundColor Cyan "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - DRYRUN: Writing XML file" Write-Host -ForegroundColor Cyan " Command would have been: $($prtgXMLOutout) | Out-File -FilePath `"$($xmlOutputDirectory)\$($xmlOutputFileName)`" -Force)" # Output the PRTG XML to the file $prtgXMLOutout | Out-File -FilePath "$xmlOutputDirectory\$xmlOutputFileName" -Force } else { Write-Host -ForegroundColor Yellow "$(Get-Date -Format "yyyy-MM-dd HH:mm:ss") - INFO: Writing XML file" # Output the PRTG XML to the file $prtgXMLOutout | Out-File -FilePath "$xmlOutputDirectory\$xmlOutputFileName" -Force } # Stop transcript Stop-Transcript
There is a little bit of configuration you will need to do:
If you plan to run this as an automation you will want to configure $appID, $tenantID and $thumbprint
$xmlOutputDirectory and $xmlOutputFileName
will likely need to be changed to where you want the XML file output
You will need to build $licenseIds
based on the licenses in your tenant that you want to monitor, I’ve included instructions on how to get the relevant information to build out the object.
If the script runs successfully your XML file will look something like this:
<?xml version="1.0" encoding="UTF-8"?> <!-- Generated 2024-04-24 15:00:02 --> <prtg> <result> <channel>A5 Faculty</channel> <value>2478</value> <LimitMode>1</LimitMode> <LimitMaxError>2560</LimitMaxError> <LimitMaxWarning>2534</LimitMaxWarning> <Unit>Custom</Unit> <CustomUnit>Licenses</CustomUnit> <Mode>Absolute</Mode> </result> <result> <channel>A1 Faculty</channel> <value>678</value> <LimitMode>1</LimitMode> <LimitMaxError>2000</LimitMaxError> <LimitMaxWarning>1980</LimitMaxWarning> <Unit>Custom</Unit> <CustomUnit>Licenses</CustomUnit> <Mode>Absolute</Mode> </result> <result> <channel>A5 Student</channel> <value>0</value> <LimitMode>1</LimitMode> <LimitMaxError>86000</LimitMaxError> <LimitMaxWarning>85140</LimitMaxWarning> <Unit>Custom</Unit> <CustomUnit>Licenses</CustomUnit> <Mode>Absolute</Mode> </result> </prtg>