Monitoring M365 license usage with PRTG

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>

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.