From 3a740d5fdb1187e58bcef3a7ef70b17026504443 Mon Sep 17 00:00:00 2001 From: Dean Cron Date: Wed, 22 Jul 2026 10:43:18 -0400 Subject: [PATCH] Fix Get-AllInsightsLicensedUsers timeout on large tenants and filename bug - Replace per-user Get-MgUserManager/Get-MgUser calls with Microsoft Graph batch requests (20 users per call) to resolve manager UPNs, cutting round-trips ~20x and avoiding timeouts/throttling on tenants with thousands of licensed users. - Add 429 retry/backoff handling for throttled batch requests. - Add progress indicator during manager resolution. - Fix output filename mismatch: script now writes and reports the same file, InsightsLicensedUsers.csv. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Get-AllInsightsLicensedUsers.ps1 | 104 ++++++++++++++++-- .../Get-AllInsightsLicensedUsers/README.md | 6 +- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/scripts/Get-AllInsightsLicensedUsers/Get-AllInsightsLicensedUsers.ps1 b/scripts/Get-AllInsightsLicensedUsers/Get-AllInsightsLicensedUsers.ps1 index c42bc5f7..ddc2e946 100644 --- a/scripts/Get-AllInsightsLicensedUsers/Get-AllInsightsLicensedUsers.ps1 +++ b/scripts/Get-AllInsightsLicensedUsers/Get-AllInsightsLicensedUsers.ps1 @@ -26,6 +26,11 @@ REQUIREMENTS: VERSION: -03132025: V1 -09042025: V2 - Rewrote to filter users by service plan in the initial Get-MgUser call. + -07222026: V3 - Replaced the per-user Get-MgUserManager/Get-MgUser calls (2 serial API calls per user) + with batched Microsoft Graph $batch requests (20 users per call) plus 429 retry/backoff + handling and a progress indicator. This avoids timeouts/throttling on tenants with + thousands of licensed users. Also fixed the export filename mismatch (script now writes + and reports the same file name). AUTHOR(S): -Dean Cron - DeanCron@microsoft.com @@ -49,20 +54,99 @@ Connect-MgGraph -Scopes "User.Read.All" -NoWelcome Write-Host "Loading All Users with the WORKPLACE_ANALYTICS_INSIGHTS_USER service plan. This might take some time..." -foregroundcolor "Yellow" $users = Get-MgUser -Filter "assignedPlans/any(c:c/servicePlanId eq b622badb-1b45-48d5-920f-4b27a2c0996c and c/capabilityStatus eq 'Enabled')" -All -ConsistencyLevel eventual -CountVariable count -Property "Id","Mail","Department" +# Resolve each user's manager UPN using the Microsoft Graph $batch endpoint instead of one +# Get-MgUserManager + Get-MgUser call per user. On tenants with thousands of licensed users, +# doing this one user at a time results in tens of thousands of serial HTTP calls, which is +# what causes this script to time out or get throttled. Batching (up to 20 sub-requests per +# call) cuts the number of round-trips by ~20x and includes 429 retry/backoff handling. +function Get-ManagerBatch { + param( + [Parameter(Mandatory)][array]$UserChunk, + [int]$RetriesRemaining = 3 + ) + + $requests = @() + $userById = @{} + for ($i = 0; $i -lt $UserChunk.Count; $i++) { + $reqId = "$i" + $requests += @{ id = $reqId; method = "GET"; url = "/users/$($UserChunk[$i].Id)/manager?`$select=id,userPrincipalName" } + $userById[$reqId] = $UserChunk[$i] + } + + $batchBody = @{ requests = $requests } | ConvertTo-Json -Depth 10 + + try { + $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/v1.0/`$batch" -Body $batchBody -ContentType "application/json" + } + catch { + if ($RetriesRemaining -gt 0) { + Write-Host "Batch request failed ($($_.Exception.Message)). Retrying in 10s..." -foregroundcolor "DarkYellow" + Start-Sleep -Seconds 10 + return Get-ManagerBatch -UserChunk $UserChunk -RetriesRemaining ($RetriesRemaining - 1) + } + Write-Host "Batch request failed after multiple retries: $($_.Exception.Message)" -foregroundcolor "Red" + $fallback = @{} + foreach ($u in $UserChunk) { $fallback[$u.Id] = $null } + return $fallback + } + + $managerMap = @{} + $throttledUsers = @() + + foreach ($resp in $batchResponse.responses) { + $user = $userById[$resp.id] + switch ($resp.status) { + 200 { $managerMap[$user.Id] = $resp.body.userPrincipalName } + 404 { $managerMap[$user.Id] = $null } + 429 { $throttledUsers += $user } + default { + Write-Host "Manager lookup failed for $($user.Mail): HTTP $($resp.status)" -foregroundcolor "DarkYellow" + $managerMap[$user.Id] = $null + } + } + } + + # Retry any sub-requests that were individually throttled (429) within the batch. + if ($throttledUsers.Count -gt 0 -and $RetriesRemaining -gt 0) { + Start-Sleep -Seconds 10 + $retryMap = Get-ManagerBatch -UserChunk $throttledUsers -RetriesRemaining ($RetriesRemaining - 1) + foreach ($key in $retryMap.Keys) { $managerMap[$key] = $retryMap[$key] } + } + elseif ($throttledUsers.Count -gt 0) { + foreach ($u in $throttledUsers) { + Write-Host "Giving up on manager lookup for $($u.Mail) after repeated throttling." -foregroundcolor "DarkYellow" + $managerMap[$u.Id] = $null + } + } + + return $managerMap +} + +Write-Host "Resolving manager UPNs for $($users.Count) users in batches of 20. This might take some time..." -foregroundcolor "Yellow" +$managerLookup = @{} +$chunkSize = 20 +$totalChunks = [Math]::Ceiling($users.Count / $chunkSize) +$chunkNum = 0 + +for ($i = 0; $i -lt $users.Count; $i += $chunkSize) { + $chunkNum++ + $endIndex = [Math]::Min($i + $chunkSize - 1, $users.Count - 1) + $chunk = $users[$i..$endIndex] + + Write-Progress -Activity "Resolving managers" -Status "Batch $chunkNum of $totalChunks" -PercentComplete (($chunkNum / $totalChunks) * 100) + + $chunkResult = Get-ManagerBatch -UserChunk $chunk + foreach ($key in $chunkResult.Keys) { $managerLookup[$key] = $chunkResult[$key] } +} +Write-Progress -Activity "Resolving managers" -Completed + # Prepare an array to hold user details $userDetails = @() -# For each user, get the required details including their manager's UPN Write-Host "Building user data for $($users.Count) users." -foregroundcolor "Yellow" foreach ($user in $users) { - $managerUpn = $null - $managerId = Get-MgUserManager -UserId $user.Id -ErrorAction SilentlyContinue - - if ($managerId) { - $manager = Get-MgUser -UserId $managerId.Id -Property UserPrincipalName - $managerUpn = $manager.UserPrincipalName - } - else{ + $managerUpn = $managerLookup[$user.Id] + if (-not $managerUpn) { Write-Host "No manager found for user: $($user.Mail)" -foregroundcolor "DarkYellow" } @@ -79,7 +163,7 @@ foreach ($user in $users) { # Export the user details to a CSV file try{ - $userDetails | Export-Csv -Path "EntraUsersExport.csv" -NoTypeInformation + $userDetails | Export-Csv -Path "InsightsLicensedUsers.csv" -NoTypeInformation Write-Host "Finished processing. See results here: .\InsightsLicensedUsers.csv" -foregroundcolor "Yellow" } catch{Write-Host "Encountered an error while exporting results: $($_)"} diff --git a/scripts/Get-AllInsightsLicensedUsers/README.md b/scripts/Get-AllInsightsLicensedUsers/README.md index ed45a3f8..fd89995d 100644 --- a/scripts/Get-AllInsightsLicensedUsers/README.md +++ b/scripts/Get-AllInsightsLicensedUsers/README.md @@ -14,7 +14,7 @@ None ### Output -The following properties are exported: +The following properties are exported to `InsightsLicensedUsers.csv`: |PersonId|ManagerId|Department @@ -23,6 +23,10 @@ All available user properties from Get-MgUser are shown here: https://learn.microsoft.com/en-us/dotnet/api/microsoft.azure.powershell.cmdlets.resources.msgraph.models.apiv10.imicrosoftgraphuser?view=az-ps-latest +### Performance on large tenants + +Manager UPNs are resolved via the Microsoft Graph `$batch` endpoint (20 users per request) instead of one `Get-MgUserManager`/`Get-MgUser` call per user. This avoids the timeouts/throttling that a fully serial, per-user approach runs into on tenants with thousands of licensed users, and includes automatic retry/backoff on HTTP 429 (throttling) responses. A progress bar shows batch-by-batch status while it runs. + ### Execution Run the script like so: