Public/Invoke-DoctorDOCollector.ps1

function Invoke-DoctorDOCollector {
    <#
    .SYNOPSIS
        Runs the DoctorDO collector: writes a local diagnostic bundle (the default) or, with -Fleet, reports this
        device's Delivery Optimization inventory and telemetry to your own DoctorDO ingest service.
    .DESCRIPTION
        Invoke-DoctorDOCollector is for a support bundle or fleet reporting; for a one-device report, start with
        New-DoctorDODeviceReport. It collects once per run and works in one of three modes, chosen by the parameters
        you pass. Run by hand at a console, it says what it is doing: one line per part of the collection as it
        finishes, then the connectivity checks counted by area and each one that failed or warned
        (-InformationAction SilentlyContinue hides that). Under SYSTEM, in an Intune remediation or scheduled task, or
        with its output redirected it writes none of it.

        LOCAL BUNDLE (the default, or -Bundle). Writes doctordo-<yyyyMMdd-HHmmss>.zip (UTC) to -OutputPath, for a
        support case or offline analysis. The zip holds manifest.json, bundle.json (the BundleV1 document) and, where
        they can be read, dosvc-registry.json, nlm-cost.txt and dsregcmd-status.txt; -IncludeLog adds doctordo.log, a
        scrubbed excerpt of the Delivery Optimization log. Nothing is uploaded. The bundle identifies the device
        unless you add -Anonymise. To read the logs, the local bundle runs Get-DeliveryOptimizationLog -Flush, which
        Microsoft documents as stopping the Delivery Optimization service (DoSvc) first; add -NoFlush to leave the
        service running.

        -ReproduceIssue turns on Delivery Optimization verbose logging while you reproduce a problem. It waits until
        you press a key (or for -ReproduceTimeoutMinutes), collects, and then restores the previous setting in a
        finally block, so an error, Ctrl+C or the timeout still restores it. Microsoft documents no way to read the
        current verbose-logging setting, so -ReproduceIssue runs only when you also pass -AssumeVerboseLoggingOff to
        confirm that it is off now. Without that switch the run stops with exit code 3 and changes nothing.

        FLEET (-Fleet -IngestUri). Sends signed reports to your own DoctorDO deployment at https://<host>/v1/ingest.
        A full inventory is sent on the first run, whenever the device's stable inventory hash changes, and as a
        keyframe: daily for the first week after the first inventory was accepted, then every -KeyframeDays days. A
        compact telemetry report is sent on every run, after the inventory when both are due. Each request is signed with the private key of the device's certificate (see -DeviceAnchor):
        no key, token or password is configured anywhere, and no TLS client certificate is presented. Only the HTTP
        status code of the reply is used; the response body is never read. -Anonymise is on by default in fleet
        mode. After an HTTP 429, 401 or 503 the collector backs off (honouring Retry-After), and runs inside that
        window exit 0 without sending. Fleet mode is meant to run as SYSTEM, from the Intune remediation script.

        REDACTION PREVIEW (-WhatIfRedaction). Prints the redaction profile (Data\redaction-profile.json in the module
        folder): what is always scrubbed and what -Anonymise changes. Collects nothing and needs no elevation.

        Every mode returns one result object; its ExitCode is also set as $LASTEXITCODE (see NOTES for the codes).
        Add -Verbose to see each step with its timing and result; the verbose lines name no host, user or address.
    .PARAMETER OutputPath
        Local bundle: the folder the zip is written to. It is created if it does not exist. Default: the temp folder
        of the account running the command ([System.IO.Path]::GetTempPath()).
    .PARAMETER Bundle
        Local bundle: selects local mode explicitly. Local mode is also the default when neither -Fleet nor
        -WhatIfRedaction is given, so this switch changes nothing else.
    .PARAMETER ReproduceIssue
        Local bundle: enable Delivery Optimization verbose logging, wait while you reproduce the problem, collect,
        then restore the previous setting. Requires -AssumeVerboseLoggingOff. Cannot be combined with -Fleet. This is
        the only option that changes a device setting, and the change is always reverted; if reverting fails the run
        exits 4 and the result's Message gives the command to run.
    .PARAMETER IncludeLog
        Local bundle: add doctordo.log to the zip, an excerpt of the Delivery Optimization log limited to the event
        patterns in the redaction profile, passed through the log-line scrub (the account forms it recognises, e-mail
        addresses and URL query strings), capped at 200 KB.
    .PARAMETER NoFlush
        Local bundle: read the Delivery Optimization log without -Flush, so DoSvc is not stopped. The newest entries
        may then be missing. Fleet runs never flush.
    .PARAMETER AssumeVerboseLoggingOff
        Local bundle, with -ReproduceIssue: states that Delivery Optimization verbose logging is off now, so the
        collector knows what to restore. Pass it only when you know the setting is off.
    .PARAMETER ReproduceTimeoutMinutes
        Local bundle, with -ReproduceIssue: how long to wait for you before collecting anyway, 1 to 60 minutes.
        Default 15.
    .PARAMETER Fleet
        Fleet mode: collect and send signed inventory and telemetry reports to -IngestUri instead of writing a zip.
        Run it as SYSTEM.
    .PARAMETER IngestUri
        Fleet: the ingest URL of your DoctorDO deployment, https://<host>/v1/ingest. A bare https://<host> is
        completed with /v1/ingest. Plain http, other paths and query strings are refused (the path is part of the
        signed request).
    .PARAMETER EntraDeviceId
        Fleet: the Microsoft Entra device ID. With entra-device it is an optional cross-check and must equal the
        DeviceId that dsregcmd /status reports; with tenant-ca it selects the certificate (read from dsregcmd or the
        inventory when omitted).
    .PARAMETER CertificateThumbprint
        Fleet: use only the certificate with this thumbprint in LocalMachine\My.
    .PARAMETER DeviceAnchor
        Fleet: which certificate identifies the device and signs each request. entra-device (default): the Entra
        device certificate (issuer MS-Organization-Access) that Windows creates when a device is Microsoft Entra
        joined or hybrid joined; nothing needs deploying. tenant-ca: the tenant's Intune SCEP or PKCS certificate.
        It must match the anchor your DoctorDO deployment was set up with.
    .PARAMETER CertificateIssuer
        Fleet, tenant-ca only: accept only certificates from this issuer (distinguished name or common name), the
        certification authority registered with your DoctorDO deployment.
    .PARAMETER KeyframeDays
        Fleet: send a full inventory at least this often even when nothing has changed, 1 to 30 days. Default 7. For
        the first week after the first inventory was accepted one is sent on every run at least 20 hours after the
        last, so a device the service could not yet place (for example, not yet in its Intune index) appears within
        a day.
    .PARAMETER ForceInventory
        Fleet: send a full inventory on this run whatever the change detection says.
    .PARAMETER EndpointCacheMinutes
        Fleet: reuse the endpoint-health results (endpoint-health.json beside the state file) for this many minutes,
        while the device is on the same subnet and the same hosts would be tested, so frequent runs do not probe
        Microsoft's endpoints every time. 0 to 10080; default 360; 0 probes on every run.
    .PARAMETER WhatIfRedaction
        Print the redaction profile as JSON and return. Nothing is collected, read or sent.
    .PARAMETER Anonymise
        Remove the host name and reduce or hash the other values that identify the device or the network (local
        subnet, default gateway, peer grouping ID, adapter name, PAC host, internal content hosts, and IPv4 and IPv6
        addresses in log text), as listed by -WhatIfRedaction. Off by default for the local bundle; on by default in fleet mode, where -Anonymise:$false
        turns it off.
    .PARAMETER SkipConnectivityChecks
        Do not probe the network: no DNS, TCP, TLS or HTTP checks, no Delivery Optimization geo request and no proxy
        auto-config (PAC) download. Use it in air-gapped or change-frozen environments. The checks are reported as
        not-collected, never as passed.
    .PARAMETER StatePath
        The collector's state file. Default: %ProgramData%\DoctorDO\state.json. The local bundle only reads it (for
        the per-device salt, so hashed identifiers are stable); fleet runs read and write it. A folder you choose here
        is never deleted or replaced by the collector.
    .PARAMETER PublicIpProbeUri
        Optional https URL of a service you trust that returns the caller's public IP address as plain text. It is
        used only when the Delivery Optimization geo answer gives no address, and supplying it turns on the public
        egress hash (as -CollectPublicEgress does). The address is hashed at once and never stored.
    .PARAMETER CollectPublicEgress
        Add a salted hash of the device's public egress address, read from the Delivery Optimization geo service's
        answer (no third-party service is called), for the source-IP checks. Off by default. The address itself is
        never stored or sent.
    .PARAMETER RangeProbeUri
        Optional extra object for the HTTP byte-range check. It is used only when its host is a Microsoft content
        host in the module's endpoint list (Data\endpoints.json). Normally not needed: a file the device itself
        downloaded is used.
    .EXAMPLE
        Invoke-DoctorDOCollector -OutputPath C:\Temp -Anonymise

        Writes an anonymised local bundle, for example C:\Temp\doctordo-20261018-093000.zip, to attach to a support
        case. Run it from an elevated prompt. Nothing leaves the device; the result shows BundlePath and Sha256.
    .EXAMPLE
        Invoke-DoctorDOCollector -OutputPath C:\Temp -ReproduceIssue -AssumeVerboseLoggingOff -IncludeLog

        Turns on Delivery Optimization verbose logging, waits while you reproduce a slow or failing download, then
        collects a bundle with the log excerpt and turns verbose logging off again, even if you press Ctrl+C.
    .EXAMPLE
        Invoke-DoctorDOCollector -WhatIfRedaction

        Prints what is always scrubbed and what -Anonymise changes, without collecting anything. Useful when your
        privacy or security team reviews the collector.
    .EXAMPLE
        $r = Invoke-DoctorDOCollector -Fleet -IngestUri https://ingest.contoso.example/v1/ingest
        Write-Output "DoctorDO: $($r.Outcome)"
        exit $r.ExitCode

        What the Intune remediation script (Remediate-DoctorDO.ps1) does, run as SYSTEM in 64-bit PowerShell with
        "Run this script using the logged-on credentials" set to No. The device signs each report with its Entra
        device key; the exit code is what Intune shows (0 success, 6 the device cannot identify itself, and so on).
    .EXAMPLE
        Invoke-DoctorDOCollector -Fleet -IngestUri https://ingest.contoso.example/v1/ingest -DeviceAnchor tenant-ca -CertificateIssuer 'CN=Contoso Issuing CA'

        Fleet run for a deployment that uses the tenant-ca anchor: the request is signed with the Intune SCEP or PKCS
        certificate issued by Contoso Issuing CA instead of the Entra device certificate.
    .EXAMPLE
        $r = Invoke-DoctorDOCollector -Fleet -IngestUri https://ingest.contoso.example/v1/ingest -ForceInventory -Verbose
        $r.Reports | Format-Table Kind, Outcome, StatusCode, CompressedBytes

        A test run on one device (as SYSTEM, for example through psexec -s): sends a full inventory and a telemetry
        report, shows each step, and lists what the service answered for each report.
    .INPUTS
        None. You cannot pipe objects to Invoke-DoctorDOCollector.
    .OUTPUTS
        System.Management.Automation.PSCustomObject. Always: ExitCode, Outcome, SchemaVersion, DurationMs.
        Local bundle: BundlePath, Sha256, DeviceId, Anonymised, NotCollected, and Message when something needs
        attention. Fleet: Reports (one Send-DoctorDOReport result per report sent), DeviceId, Anonymised,
        NotCollected, InventorySent, or Message on failure. -WhatIfRedaction: RedactionProfile.
    .NOTES
        Requirements: Windows, run elevated or as SYSTEM; Windows 10 2004 / Windows 11 or later for the full set of
        facts (on older builds whatever cannot be read is listed in NotCollected, never invented). Fleet mode needs
        SYSTEM so the device key can sign, and, for entra-device, a Microsoft Entra joined or hybrid joined device
        that is enrolled in Intune. Windows PowerShell 5.1 or PowerShell 7; no other module is needed.

        What it changes: no Delivery Optimization, firewall, proxy or service setting. The local bundle writes only its
        zip; its log flush stops DoSvc briefly (-NoFlush avoids it); -ReproduceIssue toggles verbose logging and always
        restores it. Fleet mode writes only its own state in %ProgramData%\DoctorDO (state.json and two caches, readable
        by SYSTEM and Administrators only) and the values LastSuccessfulCollection and LastReportKind under
        HKLM\SOFTWARE\DoctorDO for the Intune detection script. It never flushes logs, never enables verbose logging and
        never changes configuration. The connectivity checks connect out to the endpoints Delivery Optimization uses
        (-SkipConnectivityChecks turns them off).

        Privacy: reports go only to the -IngestUri you give, which is your own deployment; no report or device data
        goes to the DoctorDO project. Log lines pass through a scrub that replaces the account forms it recognises (profile folders after
        \Users\, UNC paths, DOMAIN\account, user SIDs, e-mail addresses); it works by pattern, so other forms can get
        through. Peer addresses are hashed in the peer list; in the endpoint list they are reduced to a /24 or /48
        network (internet peers always, other peers under -Anonymise, without which they are addresses); in log text
        -Anonymise replaces IPv4 and IPv6 addresses. The inventory's management section carries the Entra device,
        tenant and Intune device IDs, and the Entra device ID also reaches the service in the certificate that signs
        every request, whatever -Anonymise says. Run -WhatIfRedaction for the full list.

        Exit codes (ExitCode on the result and $LASTEXITCODE):
          0 success, or backing off after an HTTP 429, 401 or 503 (nothing sent this run)
          1 not elevated
          2 not running on Windows
          3 collection failed, or -ReproduceIssue without -AssumeVerboseLoggingOff
          4 verbose logging could not be restored (Message gives the command to run as administrator)
          5 fleet upload failed: network error, HTTP error, rate limited, report too large, or an IngestUri that is
             not https://<host>/v1/ingest
          6 the device cannot identify itself. entra-device: not Entra joined or hybrid joined, no usable device
             certificate, or the key could not sign (usually: not running as SYSTEM). tenant-ca: the Intune
             certificate profile has not reached the device
          7 fleet only: %ProgramData%\DoctorDO is not safe to use and could not be replaced (for example a standard
             user created it); nothing was read or sent. Delete the folder as an administrator.
    .LINK
        https://www.doctor-do.com/get-started/
    .LINK
        about_DoctorDO.Collector
    .LINK
        New-DoctorDODeviceReport
    .LINK
        Send-DoctorDOReport
    .LINK
        Get-DOInventory
    .LINK
        https://learn.microsoft.com/intune/device-management/tools/deploy-remediations
    #>

    [CmdletBinding(DefaultParameterSetName = 'Local')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(ParameterSetName = 'Local')][string] $OutputPath = [System.IO.Path]::GetTempPath(),
        [Parameter(ParameterSetName = 'Local')][switch] $Bundle,
        [Parameter(ParameterSetName = 'Local')][switch] $ReproduceIssue,
        [Parameter(ParameterSetName = 'Local')][switch] $IncludeLog,
        [Parameter(ParameterSetName = 'Local')][switch] $NoFlush,
        [Parameter(ParameterSetName = 'Local')][switch] $AssumeVerboseLoggingOff,
        [Parameter(ParameterSetName = 'Local')][ValidateRange(1, 60)][int] $ReproduceTimeoutMinutes = 15,

        [Parameter(Mandatory, ParameterSetName = 'Fleet')][switch] $Fleet,
        [Parameter(Mandatory, ParameterSetName = 'Fleet')][uri] $IngestUri,
        [Parameter(ParameterSetName = 'Fleet')][string] $EntraDeviceId,
        [Parameter(ParameterSetName = 'Fleet')][string] $CertificateThumbprint,
        [Parameter(ParameterSetName = 'Fleet')][ValidateSet('entra-device', 'tenant-ca')][string] $DeviceAnchor = 'entra-device',
        [Parameter(ParameterSetName = 'Fleet')][string] $CertificateIssuer,
        [Parameter(ParameterSetName = 'Fleet')][ValidateRange(1, 30)][int] $KeyframeDays = 7,
        [Parameter(ParameterSetName = 'Fleet')][switch] $ForceInventory,
        [Parameter(ParameterSetName = 'Fleet')][ValidateRange(0, 10080)][int] $EndpointCacheMinutes = 360,

        [Parameter(Mandatory, ParameterSetName = 'WhatIfRedaction')][switch] $WhatIfRedaction,

        [Parameter(ParameterSetName = 'Local')]
        [Parameter(ParameterSetName = 'Fleet')][switch] $Anonymise,
        [Parameter(ParameterSetName = 'Local')]
        [Parameter(ParameterSetName = 'Fleet')][switch] $SkipConnectivityChecks,
        [Parameter(ParameterSetName = 'Local')]
        [Parameter(ParameterSetName = 'Fleet')][string] $StatePath,
        [Parameter(ParameterSetName = 'Local')]
        [Parameter(ParameterSetName = 'Fleet')][string] $PublicIpProbeUri,
        [Parameter(ParameterSetName = 'Local')]
        [Parameter(ParameterSetName = 'Fleet')][switch] $CollectPublicEgress,
        [Parameter(ParameterSetName = 'Local')]
        [Parameter(ParameterSetName = 'Fleet')][string] $RangeProbeUri
    )

    $clock = [System.Diagnostics.Stopwatch]::StartNew()
    $runName = $(if ($PSCmdlet.ParameterSetName -eq 'Fleet') { 'Invoke-DoctorDOCollector -Fleet' } else { 'Invoke-DoctorDOCollector' })
    # Narrated to a person at the console: the collection's sections, then the bundle, or the two reports of a fleet run.
    $sections = @{ Local = $script:DDCollectionSections + 1; Fleet = $script:DDCollectionSections + 2; WhatIfRedaction = 0 }[$PSCmdlet.ParameterSetName]
    $quiet = Test-DDInformationQuiet -BoundParameters $PSBoundParameters
    $DDRun = Start-DDVerboseRun -Command $runName -Title $(if ($PSCmdlet.ParameterSetName -eq 'Fleet') { 'fleet report' } else { 'support bundle' }) `
        -Sections $sections -Quiet:$quiet
    $finish = {
        param($code, $outcome, $extra)
        $o = [ordered]@{ PSTypeName = 'DoctorDO.CollectorResult'; ExitCode = $code; Outcome = $outcome; SchemaVersion = 1; DurationMs = [int]$clock.ElapsedMilliseconds }
        if ($extra) { foreach ($k in $extra.Keys) { $o[$k] = $extra[$k] } }
        $global:LASTEXITCODE = $code
        Stop-DDVerboseRun -Run $DDRun -Command $runName -Summary ('{0}, exit {1}' -f $outcome, $code)
        [pscustomobject]$o
    }

    if ($WhatIfRedaction) {
        $p = Get-DDRedactionProfile
        Write-Information (ConvertTo-Json -InputObject $p -Depth 16) -InformationAction Continue
        return (& $finish 0 'RedactionProfilePrinted' @{ RedactionProfile = $p })
    }

    $isFleet = ($PSCmdlet.ParameterSetName -eq 'Fleet')
    $anonymise = $Anonymise.IsPresent -or ($isFleet -and -not $PSBoundParameters.ContainsKey('Anonymise'))
    if (-not $StatePath) { $StatePath = Get-DDDefaultStatePath }

    # ---- preflight (phase 1) -------------------------------------------------------------------------------------
    if (-not (Test-DDWindows)) { return (& $finish 2 'UnsupportedOs' @{ Message = 'The collector reads Windows state and only runs on Windows 10 2004 / Windows 11 or later.' }) }
    if (-not (Test-DDElevated)) { return (& $finish 1 'NotElevated' @{ Message = 'Run from an elevated session (or as SYSTEM): reading Delivery Optimization logs needs administrator rights.' }) }

    if ($isFleet) {
        # A standard user can create the state folder first and would own it. Fleet runs read and write it as
        # SYSTEM, so it is made safe before anything in it is used, or the run stops. Only the default folder is ever
        # replaced: a -StatePath elsewhere is the caller's folder, and the collector deletes nothing in it.
        $stateDir = Split-Path -Path $StatePath -Parent
        $isDefaultState = [string]::Equals($StatePath, (Get-DDDefaultStatePath), [System.StringComparison]::OrdinalIgnoreCase)
        $unsafe = Initialize-DDStateDirectory -Path $stateDir -AllowReset:$isDefaultState
        if ($unsafe) {
            Write-DDVerboseStep -Step 'State folder' -Result "not safe: $unsafe"
            return (& $finish 7 'StateFolderUntrusted' @{ Message = "The state folder $stateDir is not safe to use: $unsafe. A standard user may have created it. Delete it as an administrator; the next run creates it again for SYSTEM and Administrators only." })
        }
        Write-DDVerboseStep -Step 'State folder' -Result 'safe (SYSTEM and Administrators only)'
    }
    $stateRead = Read-DDState -Path $StatePath
    $state = $stateRead.State
    $now = Get-DDNowUtc
    Write-DDVerboseStep -Step 'State' -Result {
        if ($stateRead.Untrusted) { "ignored: $($stateRead.Untrusted)" }
        elseif ($stateRead.Reason -eq 'absent') { 'none yet (first run)' }
        elseif ($stateRead.Recovered) { 'unreadable, starting afresh' }
        else { 'report {0} sent last; last inventory {1}' -f $state['seq'], $(if ($state['lastInventoryAt']) { $state['lastInventoryAt'] } else { 'never' }) }
    }

    if ($isFleet) {
        # ---- fleet ---------------------------------------------------------------------------------------------
        if ($state['backoffUntil']) {
            $until = [datetime]::MinValue
            if ([datetime]::TryParse([string]$state['backoffUntil'], [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$until) -and $until -gt $now) {
                # Exit 0 while a back-off is young, so one busy moment does not mark the device as failing; after
                # $script:DDBackoffExitAfter failed runs in a row, exit 5, so Intune shows a device that cannot report.
                $failures = [int64]$(if ($state['consecutiveFailures']) { $state['consecutiveFailures'] } else { 0 })
                $code = $(if ($failures -ge $script:DDBackoffExitAfter) { 5 } else { 0 })
                return (& $finish $code 'BackingOff' @{ Message = "Backing off until $(Format-DDIso -Value $until): the last $failures run(s) could not report. The run before the back-off says why." })
            }
        }
        $since = [datetime]::MinValue
        if ($state['lastTelemetryAt']) {
            $t = [datetime]::MinValue
            if ([datetime]::TryParse([string]$state['lastTelemetryAt'], [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$t)) { $since = $t }
        }
        try {
            $collection = Get-DDRawCollection -Salt $state['salt'] -Anonymise:$anonymise -SkipConnectivityChecks:$SkipConnectivityChecks `
                -PublicIpProbeUri $PublicIpProbeUri -CollectPublicEgress:$CollectPublicEgress -RangeProbeUri $RangeProbeUri -Since $since -Mode fleet `
                -EndpointCachePath (Join-Path -Path (Split-Path -Path $StatePath -Parent) -ChildPath 'endpoint-health.json') -EndpointCacheMinutes $EndpointCacheMinutes
            $gaps = Get-DDRequiredFieldGaps -Collection $collection
            if ($gaps.Count -gt 0) { throw "required field(s) could not be collected: $($gaps -join ', ')" }
            $collection.Provisioning = Resolve-DDProvisioning -Fresh $collection.Provisioning -Stored $state['provisioning']
            $inventory = ConvertTo-DDInventory -Collection $collection
        } catch {
            return (& $finish 3 'CollectionFailed' @{ Message = "Collection failed: $($_.Exception.Message)" })
        }
        $fullHash = Get-DDInventoryHash -Inventory $inventory
        $stableHash = Get-DDStableInventoryHash -Inventory $inventory
        $utc = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal
        # For a week after the first inventory was accepted the keyframe is daily: ingest can drop a new device's report while
        # the device is not yet in the hourly Intune index, and a daily keyframe brings it back within a day, not a week (E-02).
        # "Daily" is 20 hours, so a daily schedule's start jitter cannot push the next run just under a day and skip it.
        $firstWeek = $false
        $fa = [datetime]::MinValue
        if ($state['firstInventoryAt'] -and [datetime]::TryParse([string]$state['firstInventoryAt'], [System.Globalization.CultureInfo]::InvariantCulture, $utc, [ref]$fa)) { $firstWeek = ($now - $fa).TotalDays -lt 7 }
        $keyframeDue = $true
        if ($state['lastInventoryAt']) {
            $la = [datetime]::MinValue
            if ([datetime]::TryParse([string]$state['lastInventoryAt'], [System.Globalization.CultureInfo]::InvariantCulture, $utc, [ref]$la)) {
                $keyframeDue = $(if ($firstWeek) { ($now - $la).TotalHours -ge 20 } else { ($now - $la).TotalDays -ge $KeyframeDays })
            }
        }
        $sendInventory = $ForceInventory.IsPresent -or ($stableHash -ne $state['lastStableHash']) -or $keyframeDue -or (-not $state['lastInventoryHash'])
        Start-DDSection -Name 'Inventory'
        Write-DDVerboseStep -Step 'Inventory' -Result {
            if ($ForceInventory) { 'due (-ForceInventory)' } elseif (-not $state['lastInventoryHash']) { 'due (first report)' }
            elseif ($stableHash -ne $state['lastStableHash']) { 'due (the device changed)' }
            elseif ($keyframeDue -and $firstWeek) { 'due (keyframe, daily in the first week)' } elseif ($keyframeDue) { "due (keyframe, every $KeyframeDays days)" }
            else { 'unchanged, not sent' }
        }
        $reports = New-Object 'System.Collections.Generic.List[object]'
        $sendArgs = @{ IngestUri = $IngestUri; DeviceAnchor = $DeviceAnchor }
        # The dsregcmd result from this collection, so the send does not run dsregcmd again (it can take seconds).
        if ($null -ne $collection.PSObject.Properties['Dsreg'] -and $null -ne $collection.Dsreg) { $sendArgs.DsregStatus = $collection.Dsreg }
        if ($EntraDeviceId) { $sendArgs.EntraDeviceId = $EntraDeviceId }
        elseif ($DeviceAnchor -eq 'tenant-ca' -and $inventory['management']['entraDeviceId']) { $sendArgs.EntraDeviceId = $inventory['management']['entraDeviceId'] }
        if ($CertificateThumbprint) { $sendArgs.CertificateThumbprint = $CertificateThumbprint }
        if ($CertificateIssuer) { $sendArgs.CertificateIssuer = $CertificateIssuer }

        $fail = {
            param($r)
            $state['consecutiveFailures'] = [int64]($(if ($state['consecutiveFailures']) { $state['consecutiveFailures'] } else { 0 }) + 1)
            $wait = $null
            if ($r.Outcome -eq 'RateLimited') { $wait = $(if ($null -ne $r.RetryAfterSeconds) { $r.RetryAfterSeconds } else { 900 }) }
            # 401, 408, 500, 502, 503 and 504 (Send-DoctorDOReport's BackOff) and a network error: exponential, 1 minute
            # doubling to at most an hour, or Retry-After when the service gives a longer one.
            elseif ($r.Outcome -in 'BackOff', 'NetworkError') {
                $exp = [math]::Min(3600, 60 * [math]::Pow(2, [math]::Min(6, [int]$state['consecutiveFailures'] - 1)))
                $wait = [math]::Max([int]$exp, $(if ($null -ne $r.RetryAfterSeconds) { $r.RetryAfterSeconds } else { 0 }))
                $wait = [int]($wait + (Get-Random -Minimum 0 -Maximum 30))
            }
            if ($null -ne $wait) { $state['backoffUntil'] = Format-DDIso -Value $now.AddSeconds($wait) }
            try { Save-DDState -Path $StatePath -State $state } catch { Write-Verbose "state save failed: $($_.Exception.Message)" }
            $code = $r.ExitCode
            (& $finish $code $r.Outcome @{ Message = $r.Message; Reports = $reports.ToArray(); NotCollected = $inventory['notCollected'] })
        }

        $sent = { param($r) $s = Get-DDProp $r 'StatusCode'; '{0}{1}' -f $r.Outcome, $(if ($s) { " (HTTP $s)" } else { '' }) }
        if (-not $sendInventory) { Complete-DDSection -Result 'unchanged, not sent' }
        if ($sendInventory) {
            $r = Send-DoctorDOReport -Report $inventory @sendArgs
            $reports.Add($r)
            Write-DDVerboseStep -Step 'Inventory sent' -Result { & $sent $r }
            Complete-DDSection -Result { 'sent: ' + (& $sent $r) }
            if ($r.Outcome -ne 'Accepted') { return (& $fail $r) }
            Update-DDStateAfterInventory -State $state -Collection $collection -Inventory $inventory -FullHash $fullHash -StableHash $stableHash -Now $now
            $state['consecutiveFailures'] = 0; $state['backoffUntil'] = $null
            Save-DDState -Path $StatePath -State $state
            Set-DDLastSuccess -Kind 'inventory' -Timestamp $inventory['collectedAt']
        }
        $invHash = $(if ($sendInventory) { $fullHash } else { $state['lastInventoryHash'] })
        Start-DDSection -Name 'Telemetry'
        $telemetry = New-DDTelemetryFromCollection -Collection $collection -State $state -InventoryHash $invHash -Now $now
        $r = Send-DoctorDOReport -Report $telemetry @sendArgs
        $reports.Add($r)
        Write-DDVerboseStep -Step 'Telemetry sent' -Result { & $sent $r }
        Complete-DDSection -Result { 'sent: ' + (& $sent $r) }
        if ($r.Outcome -ne 'Accepted') { return (& $fail $r) }
        Update-DDStateAfterTelemetry -State $state -Collection $collection -Telemetry $telemetry -Now $now
        $state['consecutiveFailures'] = 0; $state['backoffUntil'] = $null
        Save-DDState -Path $StatePath -State $state
        Set-DDLastSuccess -Kind 'telemetry' -Timestamp $telemetry['collectedAt']
        Write-DDRunSummary -Run $DDRun -Rows $collection.Raw['connectivity'] -NotCollected @($inventory['notCollected']).Count -More 'New-DoctorDODeviceReport explains each' -Listed 'the inventory lists each, with why'
        return (& $finish 0 'Success' @{ Reports = $reports.ToArray(); Anonymised = $anonymise; DeviceId = $inventory['device']['deviceId']; NotCollected = $inventory['notCollected']; InventorySent = $sendInventory })
    }

    # ---- local deep bundle -----------------------------------------------------------------------------------------
    $collectScript = {
        Get-DDRawCollection -Salt $state['salt'] -Anonymise:$anonymise -SkipConnectivityChecks:$SkipConnectivityChecks `
            -PublicIpProbeUri $PublicIpProbeUri -CollectPublicEgress:$CollectPublicEgress -RangeProbeUri $RangeProbeUri -Mode single -IncludeBundleExtras -FlushLogs:(-not $NoFlush)
    }
    $restoreFailed = $false
    $restoreCommand = $null
    $reproduction = $null
    try {
        if ($ReproduceIssue) {
            $assume = $null
            if ($AssumeVerboseLoggingOff) { $assume = $false }
            $cycle = Invoke-DDVerboseCycle -Collect $collectScript -TimeoutMinutes $ReproduceTimeoutMinutes -AssumePreviousState $assume
            $collection = $cycle.Result
            $restoreFailed = -not $cycle.RestoreOk
            $restoreCommand = $cycle.RestoreCommand
            # dosvcStopped: the collector never stops DoSvc itself, but the default log flush does (Microsoft: "If Flush is
            # specified, the cmdlet stops DoSvc before reading logs"); -NoFlush leaves it running.
            $reproduction = [ordered]@{ verboseEnabledByCollector = [bool]$cycle['EnabledByCollector']; endedBy = $cycle['EndedBy']; restored = [bool]$cycle.RestoreOk; dosvcStopped = (-not $NoFlush) }
        } else {
            $collection = & $collectScript
        }
    } catch {
        return (& $finish 3 'CollectionFailed' @{ Message = "Collection failed: $($_.Exception.Message)" })
    }
    if ($null -eq $collection) { return (& $finish 3 'CollectionFailed' @{ Message = 'Collection produced no data.' }) }
    $gaps = Get-DDRequiredFieldGaps -Collection $collection
    if ($gaps.Count -gt 0) { return (& $finish 3 'CollectionFailed' @{ Message = "Required field(s) could not be collected: $($gaps -join ', ')."; NotCollected = $collection.Raw['notCollected'] }) }
    try {
        $log = $null
        if ($IncludeLog -and $collection.Raw.Contains('logExcerpt')) { $log = [string]$collection.Raw['logExcerpt'] }
        # Local bundle only: the DoSvc service key, the network cost and dsregcmd /status (never in a fleet report).
        $extraArgs = @{}
        if ($null -ne $collection.PSObject.Properties['DsregText'] -and @($collection.DsregText).Count -gt 0) { $extraArgs.DsregText = [string[]]@($collection.DsregText) }
        $extras = Get-DDLocalBundleExtras -Anonymise:$anonymise -Salt $state['salt'] -ComputerName (Get-DDHostName) @extraArgs
        foreach ($m in @($extras.Missing)) { Write-Verbose "local bundle extra not collected: $m" }
        Start-DDSection -Name 'Bundle'
        $file = New-DDBundleFile -Raw $collection.Raw -OutputPath $OutputPath -LogText $log -Now $now -Extras $extras.Files -Reproduction $reproduction
        Write-DDVerboseStep -Step 'Bundle written' -Result { '{0:N0} KB; {1} fact(s) not collected' -f ((Get-Item -LiteralPath $file.BundlePath).Length / 1KB), @($collection.Raw['notCollected']).Count }
        Complete-DDSection -Result { 'zip {0:N0} KB' -f ((Get-Item -LiteralPath $file.BundlePath).Length / 1KB) }
    } catch {
        return (& $finish 3 'CollectionFailed' @{ Message = "Packaging failed: $($_.Exception.Message)" })
    }
    $summary = @{
        BundlePath = $file.BundlePath; Sha256 = $file.Sha256; DeviceId = $collection.Raw['device']['deviceId']
        Anonymised = $anonymise; NotCollected = $collection.Raw['notCollected']
    }
    Write-DDRunSummary -Run $DDRun -Rows $collection.Raw['connectivity'] -NotCollected @($collection.Raw['notCollected']).Count -More 'New-DoctorDODeviceReport explains each' -Listed 'bundle.json in the zip lists each, with why'
    if (-not $quiet) { Write-Information ("DoctorDO bundle: {0} (sha256 {1}, {2} field(s) not collected)" -f $file.BundlePath, $file.Sha256, @($collection.Raw['notCollected']).Count) -InformationAction Continue }
    if ($restoreFailed) {
        $summary.Message = "Verbose logging could not be restored. Run as administrator: $restoreCommand"
        return (& $finish 4 'VerboseNotRestored' $summary)
    }
    & $finish 0 'Success' $summary
}