Add Veeam Backup for Microsoft 365 collector

PowerShell script for the VB365 server, run hourly as a SYSTEM scheduled
task (Windows PowerShell 5.1 by default, -PwshPath for PowerShell 7).
One dashboard job per VB365 backup job and backup copy job.

- Sessions from Get-VBOJobSession (and Get-VBOCopyJobSession where
  available) within the lookback window; status from session Status,
  message from the session log's warning/error lines, size from
  Statistics.TransferredData ("1.5 GB" -> bytes)
- One VB365 server can protect several Microsoft 365 organizations, so
  the dashboard company is chosen per job: OrganizationTenants mapping,
  else Tenant, else the organization's name
- Next run from the job's NextRun; empty for disabled jobs
- Get-Vb365CollectorDiagnostics.ps1 dumps jobs and sessions, showing
  each DateTime's Kind (local vs UTC); installer adapted from MARS

Not yet run against a real server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 10:31:43 -06:00
co-authored by Claude Opus 5.5
parent dd3e2fe885
commit 3f407ba76e
6 changed files with 735 additions and 1 deletions
+354
View File
@@ -0,0 +1,354 @@
<#
.SYNOPSIS
Reports Veeam Backup for Microsoft 365 (VB365) job results to the Backup Dashboard.
.DESCRIPTION
Runs on the VB365 server, normally as a SYSTEM scheduled task every 60 minutes
(see Install-Vb365Collector.ps1). For every backup job (and backup copy job) it sends:
- the job's next scheduled run, which drives the dashboard's Overdue flag, and
- every session from the last LookbackHours that the dashboard hasn't already received in
its final state. Running sessions are re-sent each time until they finish.
One VB365 server can protect several Microsoft 365 organizations, so the dashboard company is
chosen per job from its organization: OrganizationTenants in the config maps an organization
name (e.g. "contoso.onmicrosoft.com") to a company; otherwise Tenant is used; otherwise the
organization's own name.
Already-reported sessions are tracked in a state file, and the ingest API is idempotent, so a
failed or repeated run is harmless: anything not confirmed is retried next time.
.PARAMETER ConfigPath
JSON config with ApiUrl and ApiKey (optional: Tenant, OrganizationTenants, SourceSystem,
LookbackHours).
.PARAMETER LookbackHours
Overrides the config value. Use 720 once to backfill 30 days of history.
.PARAMETER DryRun
Print the payloads instead of sending them. Doesn't touch the state or log files.
.EXAMPLE
.\Send-Vb365BackupStatus.ps1 -DryRun
.EXAMPLE
.\Send-Vb365BackupStatus.ps1 -LookbackHours 720
#>
[CmdletBinding()]
param(
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\vb365-collector.json'),
[int]$LookbackHours = 0,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
$DataDir = Split-Path -Parent $ConfigPath
$LogPath = Join-Path $DataDir 'vb365-collector.log'
$StatePath = Join-Path $DataDir 'vb365-collector.state.json'
$MaxLogBytes = 5MB
$Invariant = [Globalization.CultureInfo]::InvariantCulture
# ---------- Helpers ----------
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Host $line
if ($DryRun) { return }
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
Move-Item $LogPath "$LogPath.1" -Force
}
Add-Content -Path $LogPath -Value $line -Encoding UTF8
}
function Get-ErrorText($ErrorRecord) {
# Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)"
}
return $ErrorRecord.Exception.Message
}
# The API needs an explicit UTC offset. DateTimeOffset keeps a Local/Unspecified time as local and a
# Utc time as +00:00.
function ConvertTo-IsoTime($Value) {
if ($null -eq $Value) { return $null }
$dt = [datetime]$Value
if ($dt.Year -le 1900) { return $null } # "not set" (DateTime.MinValue and friends)
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
}
# First property of $Object, out of $Names, that exists and isn't empty. The VB365 object model isn't
# fully documented, so reads go through this rather than assuming one spelling.
function Get-FirstValue($Object, [string[]]$Names) {
if ($null -eq $Object) { return $null }
foreach ($name in $Names) {
try {
$prop = $Object.PSObject.Properties[$name]
if (-not $prop) { continue }
$value = $prop.Value
# Check emptiness by type: casting to [string] would make a one-item list of objects ''
if ($null -eq $value) { continue }
if ($value -is [string] -and $value -eq '') { continue }
if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue }
return $value
} catch { }
}
return $null
}
function Import-Vb365 {
if (Get-Command Get-VBOJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell) {
Import-Module Veeam.Archiver.PowerShell
return
}
# Fall back to the default install path
Import-Module (Join-Path $env:ProgramFiles 'Veeam\Backup365\Veeam.Archiver.PowerShell\Veeam.Archiver.PowerShell.psd1')
}
function Get-Time($Object, [string]$Field) {
$value = Get-FirstValue $Object @($Field)
if ($null -eq $value) { return $null }
$dt = [datetime]$value
if ($dt.Year -le 1900) { return $null } # e.g. EndTime while a session is running
return $dt
}
# "1.5 GB", "512 MB", "123456" -> bytes; $null when it can't be read
function ConvertTo-Bytes($Value) {
if ($null -eq $Value) { return $null }
if ($Value -is [ValueType]) { return [long]$Value }
$match = [regex]::Match([string]$Value, '^\s*([\d.,]+)\s*([KMGTP]?B)?\s*$', 'IgnoreCase')
if (-not $match.Success) { return $null }
$number = 0.0
if (-not [double]::TryParse(($match.Groups[1].Value -replace ',', '.'), [Globalization.NumberStyles]::Float, $Invariant, [ref]$number)) {
return $null
}
$power = @{ '' = 0; 'B' = 0; 'KB' = 1; 'MB' = 2; 'GB' = 3; 'TB' = 4; 'PB' = 5 }[$match.Groups[2].Value.ToUpperInvariant()]
return [long]($number * [math]::Pow(1024, $power))
}
# ---------- Jobs and tenants ----------
function Get-OrganizationName($Job) {
$org = Get-FirstValue $Job @('Organization')
if ($null -eq $org) {
# Copy jobs point at their source backup job
$org = Get-FirstValue (Get-FirstValue $Job @('BackupJob')) @('Organization')
}
if ($org -is [string]) { return $org }
$name = Get-FirstValue $org @('Name', 'OfficeName', 'DisplayName')
if ($name) { return [string]$name }
return $null
}
function Get-TenantForJob($Job) {
$org = Get-OrganizationName $Job
if ($org -and $Config.OrganizationTenants) {
$mapped = $Config.OrganizationTenants.PSObject.Properties[$org] # case-insensitive lookup
if ($mapped -and $mapped.Value) { return [string]$mapped.Value }
}
if ($Config.Tenant) { return [string]$Config.Tenant }
if ($org) { return $org }
return $env:COMPUTERNAME
}
function Get-NextRun($Job) {
try {
$enabled = Get-FirstValue $Job @('IsEnabled', 'Enabled')
if ($enabled -eq $false) { return $null }
return ConvertTo-IsoTime (Get-Time $Job 'NextRun')
} catch {
Write-Log "Couldn't read schedule for job '$($Job.Name)': $(Get-ErrorText $_)" 'WARN'
return $null
}
}
# ---------- Sessions ----------
function Get-RunStatus($Session) {
$status = [string](Get-FirstValue $Session @('Status', 'Result', 'State'))
if ($status -match 'Fail|Error') { return 'failed' }
if ($status -match 'Warn') { return 'warning' }
if ($status -match 'Succe') { return 'success' }
if ($status -match 'Run|Queue|Progress|Start|Wait') { return 'running' }
if (-not (Get-Time $Session 'EndTime')) { return 'running' }
# Stopped (cancelled), Disconnected, or a state we don't recognise: surface it rather than hide it
return 'warning'
}
# Warning/error lines from the session log
function Get-RunMessage($Session, [string]$Status) {
if ($Status -eq 'running') { return '' }
$lines = New-Object 'System.Collections.Generic.List[string]'
$sessionStatus = [string](Get-FirstValue $Session @('Status', 'Result', 'State'))
if ($Status -ne 'success') {
foreach ($entry in @(Get-FirstValue $Session @('Log', 'Logs') | Where-Object { $_ })) {
$title = [string](Get-FirstValue $entry @('Title', 'Message', 'Text'))
$entryText = "$title $([string](Get-FirstValue $entry @('Status', 'Type', 'Severity')))"
if ($title -and $entryText -match 'Warn|Fail|Error') {
$title = $title.Trim()
if (-not $lines.Contains($title)) { $lines.Add($title) }
}
}
}
if ($lines.Count -eq 0) {
if ($Status -eq 'success') { return 'Job completed successfully.' }
return "Job finished with status '$sessionStatus'."
}
if ($sessionStatus -and $sessionStatus -notmatch 'Fail|Warn') { $lines.Insert(0, "Session status: $sessionStatus") }
return ($lines -join "`n")
}
function ConvertTo-Run($Session, $Job, [string]$JobKind) {
$status = Get-RunStatus $Session
$details = [ordered]@{ vb365_server = $env:COMPUTERNAME; job_kind = $JobKind }
$org = Get-OrganizationName $Job
if ($org) { $details.organization = $org }
$sessionStatus = Get-FirstValue $Session @('Status', 'Result', 'State')
if ($sessionStatus) { $details.vb365_status = [string]$sessionStatus }
$run = [ordered]@{
external_id = [string](Get-FirstValue $Session @('Id', 'SessionId'))
status = $status
started_at = ConvertTo-IsoTime (Get-Time $Session 'CreationTime')
message = Get-RunMessage $Session $status
details = $details
}
if ($status -ne 'running') {
$finished = ConvertTo-IsoTime (Get-Time $Session 'EndTime')
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
$stats = Get-FirstValue $Session @('Statistics')
$processed = Get-FirstValue $stats @('ProcessedObjects')
if ($null -ne $processed) { $details.processed_objects = $processed }
$transferred = ConvertTo-Bytes (Get-FirstValue $stats @('TransferredData', 'TransferredDataSize'))
if ($transferred -gt 0) { $run.size_bytes = $transferred }
}
return $run
}
function Send-Report($Body) {
$json = $Body | ConvertTo-Json -Depth 6 -Compress
if ($DryRun) {
Write-Host $json
return
}
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1
$params = @{
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
Method = 'Post'
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
ContentType = 'application/json; charset=utf-8'
Body = [Text.Encoding]::UTF8.GetBytes($json)
TimeoutSec = 30
}
Invoke-RestMethod @params | Out-Null
}
# ---------- Main ----------
try {
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
foreach ($key in 'ApiUrl', 'ApiKey') {
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
}
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam Backup for M365' }
if ($LookbackHours -le 0) {
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
}
# Windows PowerShell 5.1 may default to TLS 1.0
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Import-Vb365
# Session ID -> start time (Unix seconds), for sessions already delivered in their final state
$sent = @{}
if (Test-Path $StatePath) {
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
}
$since = (Get-Date).AddHours(-$LookbackHours)
# (job, kind, sessions) for backup jobs, plus backup copy jobs where the cmdlets exist
$work = New-Object 'System.Collections.Generic.List[object]'
foreach ($job in @(Get-VBOJob)) {
$sessions = @(Get-VBOJobSession -Job $job | Where-Object { (Get-Time $_ 'CreationTime') -ge $since })
$work.Add([pscustomobject]@{ Job = $job; Kind = 'backup'; Sessions = $sessions })
}
if ((Get-Command Get-VBOCopyJob -ErrorAction SilentlyContinue) -and (Get-Command Get-VBOCopyJobSession -ErrorAction SilentlyContinue)) {
$copyJobs = @(Get-VBOCopyJob)
if ($copyJobs.Count) {
# Fetch once and match by job ID, since the per-job parameter name isn't consistent
$copySessions = @(Get-VBOCopyJobSession | Where-Object { (Get-Time $_ 'CreationTime') -ge $since })
foreach ($job in $copyJobs) {
$id = [string]$job.Id
$work.Add([pscustomobject]@{
Job = $job; Kind = 'copy'
Sessions = @($copySessions | Where-Object { [string](Get-FirstValue $_ @('JobId')) -eq $id })
})
}
}
}
} catch {
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
exit 2
}
$failures = 0
$runsSent = 0
foreach ($item in $work) {
$job = $item.Job
$tenant = Get-TenantForJob $job
$jobInfo = [ordered]@{
external_id = [string]$job.Id
name = [string]$job.Name
# Always sent (null when disabled/unscheduled) so the dashboard's value tracks VB365's
next_run_at = Get-NextRun $job
}
$pending = @($item.Sessions |
Where-Object { $_ -and -not $sent.ContainsKey([string](Get-FirstValue $_ @('Id', 'SessionId'))) } |
Sort-Object { Get-Time $_ 'CreationTime' })
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-Report ([ordered]@{ tenant = $tenant; source_system = $SourceSystem; job = $jobInfo })
}
foreach ($session in $pending) {
$run = ConvertTo-Run $session $job $item.Kind
Send-Report ([ordered]@{ tenant = $tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
$runsSent++
if ($run.status -ne 'running') {
$sent[$run.external_id] = ([DateTimeOffset](Get-Time $session 'CreationTime')).ToUnixTimeSeconds()
}
}
} catch {
$failures++
Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR'
$httpStatus = 0
try { $httpStatus = [int]$_.Exception.Response.StatusCode } catch { }
if ($httpStatus -eq 401) {
Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR'
break
}
}
}
if (-not $DryRun) {
# Forget sessions that have aged out of the lookback window (plus a day of slack)
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
$keep = [ordered]@{}
foreach ($id in $sent.Keys) {
if ($sent[$id] -ge $cutoff) { $keep[$id] = $sent[$id] }
}
try {
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
} catch {
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
}
}
Write-Log ("Reported {0} jobs, {1} runs, {2} failures" -f $work.Count, $runsSent, $failures)
if ($failures) { exit 1 }