Fix VB365 collector against a real server's object model

Checked with Get-Vb365CollectorDiagnostics.ps1 output from a VB365 8.4
server (GDC-VEEAM-04, 11 organizations, Windows PowerShell 5.1). Times
(session CreationTime/EndTime, job NextRun) are local, and property
names matched. Two fixes:

- Session log items carry their status as a Title prefix ("[Warning]
  ...") with no separate status property. Match on the prefix and strip
  it, instead of matching words in the text, which would have pulled in
  lines like "[Success] Processing site: .../error-reports"
- Retries are separate sessions (MainSessionId -> the retried session,
  which has JobWillBeRetried = True). Mark them in details (retry_of,
  will_retry) and say "Veeam will retry this job." on a pending retry

Mock rebuilt on the real shapes, including a warning + retry pair.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-08 10:47:40 -06:00
co-authored by Claude Opus 5.5
parent 3f407ba76e
commit 118a7f978d
3 changed files with 81 additions and 55 deletions
+6 -5
View File
@@ -3,10 +3,10 @@
A PowerShell script that runs on the Veeam Backup for Microsoft 365 (VB365) server and reports its A PowerShell script that runs on the Veeam Backup for Microsoft 365 (VB365) server and reports its
backup jobs (and backup copy jobs) to the dashboard, one dashboard job per VB365 job. backup jobs (and backup copy jobs) to the dashboard, one dashboard job per VB365 job.
> **Not yet checked against a real server.** The cmdlets are VB365's own (`Get-VBOJob`, > **Checked against:** VB365 8.4 under Windows PowerShell 5.1, a shared server protecting 11
> `Get-VBOJobSession`), but the properties of the objects they return aren't fully documented. > organizations: successful, warning and retried sessions. Not yet seen on a real server: a failed
> Before installing on the first server, run `Get-Vb365CollectorDiagnostics.ps1` and the collector > session, a session in progress, or a backup copy job. If one looks wrong, run
> with `-DryRun` (see below) to confirm what it reads. > `Get-Vb365CollectorDiagnostics.ps1` while it's in that state and send the output.
## Which company a job appears under ## Which company a job appears under
@@ -63,7 +63,8 @@ Re-run the installer to change settings (including the organization mapping) or
| Status | Session status: Success / Warning / Failed. Running or queued → **Running**. Stopped (cancelled) or unrecognised → **Warning** | | Status | Session status: Success / Warning / Failed. Running or queued → **Running**. Stopped (cancelled) or unrecognised → **Warning** |
| Started / finished | Session `CreationTime` / `EndTime` | | Started / finished | Session `CreationTime` / `EndTime` |
| Next scheduled | Job's `NextRun`. Empty for disabled jobs | | Next scheduled | Job's `NextRun`. Empty for disabled jobs |
| Message | Warning and error lines from the session log | | Message | Warning and error lines from the session log (items titled `[Warning] ...`, `[Failed] ...`), plus "Veeam will retry this job." when a retry is pending |
| Retries | VB365 runs a retry as a separate session, so it appears as its own run, marked `details.retry_of` (the session it retried). The retried run is marked `details.will_retry` |
| Size | Data transferred (`Statistics.TransferredData`) | | Size | Data transferred (`Statistics.TransferredData`) |
| Details | Organization, objects processed, job kind (`backup` / `copy`) | | Details | Organization, objects processed, job kind (`backup` / `copy`) |
+27 -9
View File
@@ -177,26 +177,40 @@ function Get-RunStatus($Session) {
return 'warning' return 'warning'
} }
# Warning/error lines from the session log function Test-EmptyGuid($Value) {
return (-not $Value) -or [string]$Value -eq [string][guid]::Empty
}
# Warning/error lines from the session log. Each log item's status is a prefix of its Title, e.g.
# "[Warning] Item was skipped" (there's no separate status property), so match on that rather than
# on words in the text, which would catch e.g. "[Success] Processing site: .../error-reports".
function Get-RunMessage($Session, [string]$Status) { function Get-RunMessage($Session, [string]$Status) {
if ($Status -eq 'running') { return '' } if ($Status -eq 'running') { return '' }
$lines = New-Object 'System.Collections.Generic.List[string]' $lines = New-Object 'System.Collections.Generic.List[string]'
$sessionStatus = [string](Get-FirstValue $Session @('Status', 'Result', 'State')) $sessionStatus = [string](Get-FirstValue $Session @('Status', 'Result', 'State'))
if ($Status -ne 'success') { if ($Status -ne 'success') {
foreach ($entry in @(Get-FirstValue $Session @('Log', 'Logs') | Where-Object { $_ })) { foreach ($entry in @(Get-FirstValue $Session @('Log', 'Logs') | Where-Object { $_ })) {
$title = [string](Get-FirstValue $entry @('Title', 'Message', 'Text')) $title = ([string](Get-FirstValue $entry @('Title', 'Message', 'Text'))).Trim()
$entryText = "$title $([string](Get-FirstValue $entry @('Status', 'Type', 'Severity')))" $prefix = [regex]::Match($title, '^\[(\w+)\]\s*')
if ($title -and $entryText -match 'Warn|Fail|Error') { if ($prefix.Success) {
$title = $title.Trim() if ($prefix.Groups[1].Value -notmatch 'Warn|Fail|Error') { continue }
if (-not $lines.Contains($title)) { $lines.Add($title) } $title = $title.Substring($prefix.Length)
} elseif ([string](Get-FirstValue $entry @('Status', 'Type', 'Severity')) -notmatch 'Warn|Fail|Error') {
continue
} }
if ($title -and -not $lines.Contains($title)) { $lines.Add($title) }
} }
} }
if ($lines.Count -eq 0) { if ($lines.Count -eq 0) {
if ($Status -eq 'success') { return 'Job completed successfully.' } if ($Status -eq 'success') { $lines.Add('Job completed successfully.') }
return "Job finished with status '$sessionStatus'." else { $lines.Add("Job finished with status '$sessionStatus'.") }
} elseif ($sessionStatus -and $sessionStatus -notmatch 'Fail|Warn') {
$lines.Insert(0, "Session status: $sessionStatus")
}
# A warning/failure VB365 is about to retry; the retry arrives as its own run
if ($Status -ne 'success' -and (Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) {
$lines.Add('Veeam will retry this job.')
} }
if ($sessionStatus -and $sessionStatus -notmatch 'Fail|Warn') { $lines.Insert(0, "Session status: $sessionStatus") }
return ($lines -join "`n") return ($lines -join "`n")
} }
@@ -207,6 +221,10 @@ function ConvertTo-Run($Session, $Job, [string]$JobKind) {
if ($org) { $details.organization = $org } if ($org) { $details.organization = $org }
$sessionStatus = Get-FirstValue $Session @('Status', 'Result', 'State') $sessionStatus = Get-FirstValue $Session @('Status', 'Result', 'State')
if ($sessionStatus) { $details.vb365_status = [string]$sessionStatus } if ($sessionStatus) { $details.vb365_status = [string]$sessionStatus }
# Retries are separate sessions pointing at the session they retry
$mainSession = Get-FirstValue $Session @('MainSessionId')
if (-not (Test-EmptyGuid $mainSession)) { $details.retry_of = [string]$mainSession }
if ((Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) { $details.will_retry = $true }
$run = [ordered]@{ $run = [ordered]@{
external_id = [string](Get-FirstValue $Session @('Id', 'SessionId')) external_id = [string](Get-FirstValue $Session @('Id', 'SessionId'))
+48 -41
View File
@@ -1,5 +1,8 @@
# Stand-ins for the Veeam Backup for Microsoft 365 cmdlets. Object shapes are best guesses until # Stand-ins for the Veeam Backup for Microsoft 365 cmdlets. Object shapes follow
# checked against Get-Vb365CollectorDiagnostics.ps1 output from a real server. # Get-Vb365CollectorDiagnostics.ps1 output from a real VB365 8.4 server: session and NextRun times
# are local, Statistics.TransferredData is a string ("187.2 MB"), log items carry their status as a
# Title prefix ("[Warning] ..."), and retries are separate sessions whose MainSessionId points at
# the session they retry (which has JobWillBeRetried = True).
# #
# . ./tests/MockVb365.ps1 # . ./tests/MockVb365.ps1
# ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun # ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
@@ -10,62 +13,66 @@ function New-StableGuid([string]$Seed) {
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed))) [guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
} }
$acme = [pscustomobject]@{ Name = 'acme.onmicrosoft.com'; Type = 'Office365' } function New-MockOrg($Name) { [pscustomobject]@{ Name = $Name; OfficeName = $Name; Type = 'Office365'; IsBackedUp = $true } }
$blue = [pscustomobject]@{ Name = 'bluewater.onmicrosoft.com'; Type = 'Office365' } $acme = New-MockOrg 'acme.onmicrosoft.com'
$blue = New-MockOrg 'bluewater.onmicrosoft.com'
function New-MockJob($Name, $Org, $NextRun, [bool]$Enabled = $true) { function New-MockJob($Name, $Org, $NextRun, [bool]$Enabled = $true) {
[pscustomobject]@{ [pscustomobject]@{
Id = New-StableGuid $Name; Name = $Name; Organization = $Org Id = New-StableGuid $Name; Name = $Name; Organization = $Org; JobType = 'Backup'
IsEnabled = $Enabled; NextRun = $NextRun; LastStatus = 'Success' IsEnabled = $Enabled; NextRun = $NextRun; LastStatus = 'Success'
LastRun = $(if ($NextRun) { $NextRun.AddDays(-1).ToUniversalTime() }) # UTC, as on the real server
} }
} }
function New-MockSession($Job, $Start, $End, $Status, $Transferred = '0 B', [int]$Objects = 0, $Log = @()) { function New-LogItem($Status, $Text, $At) {
[pscustomobject]@{ Id = [guid]::NewGuid(); USN = 1; Title = "[$Status] $Text"; CreationTime = $At; EndTime = $At }
}
function New-MockSession($Job, $Start, $End, $Status, $Transferred = '0 B', [long]$Objects = 0, $Log = @(),
$MainSessionId = [guid]::Empty, [bool]$WillRetry = $false) {
$logItems = @((New-LogItem 'Success' "Job started at: $($Start.ToString('G'))" $Start)) + @($Log)
[pscustomobject]@{ [pscustomobject]@{
Id = New-StableGuid "$($Job.Name)|$($Start.Ticks)"; JobId = $Job.Id; JobName = $Job.Name JobId = $Job.Id; JobName = $Job.Name; Status = $Status
CreationTime = $Start; EndTime = $(if ($End) { $End } else { [datetime]::MinValue }) Statistics = [pscustomobject]@{ ProcessingRate = '1.6 MB/s (4 items/s)'; TransferredData = $Transferred; ProcessedObjects = $Objects; Bottleneck = 'Target' }
Status = $Status JobSessionConfigType = 'Incremental'
Statistics = [pscustomobject]@{ ProcessedObjects = $Objects; TransferredData = $Transferred } Id = New-StableGuid "$($Job.Name)|$($Start.Ticks)"
Log = $Log CreationTime = $Start
EndTime = $(if ($End) { $End } else { [datetime]::MinValue })
Progress = 49; MainSessionId = $MainSessionId; JobWillBeRetried = $WillRetry
Log = $logItems
} }
} }
function New-LogEntry($Title, $Status = 'Success') { [pscustomobject]@{ Title = $Title; Status = $Status } }
$global:MockVboJobs = @( $global:MockVboJobs = @(
(New-MockJob 'Acme - Exchange & OneDrive' $acme $now.AddHours(3)), (New-MockJob 'Acme 365 Backup' $acme $now.Date.AddHours(17).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))),
(New-MockJob 'Acme - SharePoint & Teams' $acme $now.AddHours(5)), (New-MockJob 'Bluewater 365 Backup' $blue $now.Date.AddHours(17.75).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))),
(New-MockJob 'Bluewater - Full Org' $blue $now.AddMinutes(30)), (New-MockJob 'Bluewater Old Mailboxes' $blue $now.AddDays(1) $false) # disabled
(New-MockJob 'Bluewater - Old Mailboxes' $blue $now.AddDays(1) $false) # disabled
) )
$exo, $spo, $bw, $old = $global:MockVboJobs $acmeJob, $blueJob, $oldJob = $global:MockVboJobs
$y = $now.Date.AddDays(-1) # yesterday
$blueWarn = New-MockSession $blueJob $y.AddHours(17.75) $y.AddHours(17.75).AddMinutes(3.5) 'Warning' '32.9 MB' 47 @(
(New-LogItem 'Success' 'Found 94 objects' $y.AddHours(17.75)),
# A success line mentioning "error" must not end up in the message
(New-LogItem 'Success' 'Processing site: https://bluewater.sharepoint.com/sites/error-reports' $y.AddHours(17.76)),
(New-LogItem 'Warning' 'Processing mailbox [email protected] finished with warning: Item "Q3.pst" exceeds the maximum size' $y.AddHours(17.78))
) -WillRetry $true
$global:MockVboSessions = @( $global:MockVboSessions = @(
(New-MockSession $exo $now.AddHours(-21) $now.AddHours(-20.5) 'Success' '1.5 GB' 412 @( (New-MockSession $acmeJob $y.AddHours(17) $y.AddHours(17).AddMinutes(4.8) 'Success' '187.2 MB' 568 @(
(New-LogEntry 'Processing mailbox [email protected]'), (New-LogEntry 'Job finished'))), (New-LogItem 'Success' 'Found 49 objects' $y.AddHours(17)), (New-LogItem 'Success' 'Connected to organization' $y.AddHours(17)))),
(New-MockSession $exo $now.AddHours(-1) $null 'Running' '0 B' 10), (New-MockSession $acmeJob $now.AddMinutes(-20) $null 'Running' '0 B' 12),
(New-MockSession $spo $now.AddHours(-19) $now.AddHours(-18) 'Warning' '512 MB' 38 @( $blueWarn,
(New-LogEntry 'Processing site Marketing'), # Its retry 13 minutes later, which succeeded
(New-LogEntry 'Item "Budget 2026.xlsx" was skipped: file is locked' 'Warning'))), (New-MockSession $blueJob $y.AddHours(17.75).AddMinutes(13.5) $y.AddHours(17.75).AddMinutes(16.8) 'Success' '0 B' 0 -MainSessionId $blueWarn.Id),
(New-MockSession $bw $now.AddHours(-6) $now.AddHours(-5.9) 'Failed' '0 B' 0 @( (New-MockSession $blueJob $now.AddHours(-30) $now.AddHours(-29.9) 'Failed' '0 B' 0 @(
(New-LogEntry 'Failed to connect to Exchange Online: (401) Unauthorized' 'Failed'))), (New-LogItem 'Failed' 'Failed to connect to organization: The remote server returned an error: (401) Unauthorized.' $now.AddHours(-30)))),
(New-MockSession $bw $now.AddHours(-30) $now.AddHours(-29.5) 'Stopped' '2 GB' 120), # cancelled (New-MockSession $oldJob $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Success') # outside 48h lookback
(New-MockSession $old $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Success') # outside 48h lookback
)
# A backup copy job (to object storage) for Acme's mailbox job
$global:MockVboCopyJobs = @(
[pscustomobject]@{ Id = New-StableGuid 'acme-copy'; Name = 'Acme - Exchange copy to Wasabi'; BackupJob = $exo; IsEnabled = $true; NextRun = $null }
)
$global:MockVboCopySessions = @(
[pscustomobject]@{
Id = New-StableGuid 'acme-copy-s1'; JobId = $global:MockVboCopyJobs[0].Id; CreationTime = $now.AddHours(-20); EndTime = $now.AddHours(-19.8)
Status = 'Success'; Statistics = [pscustomobject]@{ ProcessedObjects = 412; TransferredData = '1.4 GB' }; Log = @()
}
) )
function Get-VBOJob { $global:MockVboJobs } function Get-VBOJob { $global:MockVboJobs }
function Get-VBOJobSession { param($Job) $global:MockVboSessions | Where-Object { -not $Job -or $_.JobId -eq $Job.Id } } function Get-VBOJobSession { param($Job) $global:MockVboSessions | Where-Object { -not $Job -or $_.JobId -eq $Job.Id } }
function Get-VBOOrganization { $acme, $blue } function Get-VBOOrganization { $acme, $blue }
function Get-VBOCopyJob { $global:MockVboCopyJobs } function Get-VBOCopyJob { } # none, as on the real server
function Get-VBOCopyJobSession { $global:MockVboCopySessions } function Get-VBOCopyJobSession { }