Public/Invoke-AzLocalPipelineTimedOperation.ps1
|
function Invoke-AzLocalPipelineTimedOperation { <# .SYNOPSIS Executes one pipeline operation and records its wall-clock timing. .DESCRIPTION Maintains a durable JSON timing report shared by separate PowerShell tasks in one pipeline run. The report is written before execution and updated in a finally block, so failed operations remain visible and an abruptly terminated process leaves a valid Running record. Step numbers are ordering keys rather than contiguous indexes. Use 10, 20, 30, and so on to leave room for future operations. .PARAMETER PipelineName Stable pipeline identifier, such as monitor-updates. .PARAMETER StepNumber Loose ordering number for this operation. .PARAMETER StepName Human-readable operation name. .PARAMETER ScriptBlock Workload to execute. Output and errors retain their normal behavior. .PARAMETER Enabled Controls timing capture. When false, ScriptBlock executes directly without creating a diagnostics file. .PARAMETER Path Timing report path. Defaults to ./diagnostics/pipeline-timings.json. .PARAMETER PipelineVersion Pipeline template version. Defaults to GENERATED_AGAINST_MODULE_VERSION. .OUTPUTS The unmodified output emitted by ScriptBlock. #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $true)] [ValidatePattern('^[A-Za-z0-9._-]{1,100}$')] [string]$PipelineName, [Parameter(Mandatory = $true)] [ValidateRange(1, 9990)] [int]$StepNumber, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [ValidateLength(1, 200)] [string]$StepName, [Parameter(Mandatory = $true)] [ValidateNotNull()] [scriptblock]$ScriptBlock, [Parameter()] [bool]$Enabled = $true, [Parameter()] [ValidateNotNullOrEmpty()] [string]$Path = $( if ($env:AZLOCAL_PIPELINE_TIMING_PATH) { $env:AZLOCAL_PIPELINE_TIMING_PATH } else { Join-Path -Path 'diagnostics' -ChildPath 'pipeline-timings.json' } ), [Parameter()] [AllowEmptyString()] [string]$PipelineVersion = $env:GENERATED_AGAINST_MODULE_VERSION ) if (-not $Enabled) { $workloadSucceeded = $false try { & $ScriptBlock $workloadSucceeded = $true } finally { if ($workloadSucceeded) { $global:LASTEXITCODE = 0 } } return } $startedUtc = [datetime]::UtcNow $invocationId = [guid]::NewGuid().ToString('D') $module = Get-Module AzLocal.UpdateManagement | Sort-Object Version -Descending | Select-Object -First 1 $moduleVersion = if ($module) { [string]$module.Version } else { '' } $platform = if ($env:GITHUB_ACTIONS -eq 'true') { 'GitHubActions' } elseif ($env:TF_BUILD -eq 'True') { 'AzureDevOps' } else { 'Local' } $runId = if ($platform -eq 'GitHubActions') { [string]$env:GITHUB_RUN_ID } elseif ($platform -eq 'AzureDevOps') { [string]$env:BUILD_BUILDID } else { '' } $runAttempt = if ($platform -eq 'GitHubActions') { [string]$env:GITHUB_RUN_ATTEMPT } elseif ($platform -eq 'AzureDevOps') { [string]$env:SYSTEM_JOBATTEMPT } else { '' } $operation = [ordered]@{ stepNumber = $StepNumber stepName = $StepName invocationId = $invocationId startedUtc = $startedUtc.ToString('o') endedUtc = $null durationMs = $null status = 'Running' errorType = $null errorMessage = $null } $writeReport = { param([System.Collections.IDictionary]$CurrentOperation) try { $parent = Split-Path -Parent $Path if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force -WhatIf:$false | Out-Null } $operations = [System.Collections.Generic.List[object]]::new() $reportStartedUtc = $startedUtc if (Test-Path -LiteralPath $Path -PathType Leaf) { try { $existing = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if ($existing.PSObject.Properties['startedUtc'] -and $existing.startedUtc) { [datetime]$parsedStart = [datetime]::MinValue if ([datetime]::TryParse([string]$existing.startedUtc, [ref]$parsedStart)) { $reportStartedUtc = $parsedStart.ToUniversalTime() } } foreach ($item in @($existing.operations)) { if ($item -and [string]$item.invocationId -ne $invocationId) { [void]$operations.Add($item) } } } catch { Write-Warning ("Pipeline timing report could not be read and will be rebuilt: {0}" -f $_.Exception.Message) } } [void]$operations.Add([pscustomobject]$CurrentOperation) $orderedOperations = @($operations.ToArray() | Sort-Object stepNumber, startedUtc) $completed = @($orderedOperations | Where-Object { $_.status -ne 'Running' }) $failed = @($orderedOperations | Where-Object { $_.status -eq 'Failed' }) $nowUtc = [datetime]::UtcNow $reportStatus = if ($failed.Count -gt 0) { 'Failed' } elseif ($completed.Count -eq $orderedOperations.Count) { 'Succeeded' } else { 'Running' } $report = [ordered]@{ schemaVersion = 1 pipelineName = $PipelineName pipelineVersion = [string]$PipelineVersion platform = $platform runId = $runId runAttempt = $runAttempt moduleVersion = $moduleVersion powerShellVersion = [string]$PSVersionTable.PSVersion powerShellEdition = [string]$PSVersionTable.PSEdition startedUtc = $reportStartedUtc.ToString('o') lastUpdatedUtc = $nowUtc.ToString('o') wallClockDurationMs = [int64][math]::Round(($nowUtc - $reportStartedUtc).TotalMilliseconds, 0) status = $reportStatus operationCount = $orderedOperations.Count operations = $orderedOperations } $json = $report | ConvertTo-Json -Depth 8 $tempPath = '{0}.{1}.tmp' -f $Path, $invocationId Write-Utf8NoBomFile -Path $tempPath -Content $json Move-Item -LiteralPath $tempPath -Destination $Path -Force -WhatIf:$false } catch { Write-Warning ("Pipeline timing telemetry write failed for step {0} '{1}': {2}" -f $StepNumber, $StepName, $_.Exception.Message) } } & $writeReport $operation Write-Verbose ("Pipeline timing started: pipeline='{0}', step={1}, name='{2}', invocationId={3}." -f $PipelineName, $StepNumber, $StepName, $invocationId) try { & $ScriptBlock $operation.status = 'Succeeded' } catch { $operation.status = 'Failed' $operation.errorType = $_.Exception.GetType().FullName $operation.errorMessage = ConvertTo-ScrubbedCliOutput -Text $_.Exception.Message throw } finally { $endedUtc = [datetime]::UtcNow $operation.endedUtc = $endedUtc.ToString('o') $operation.durationMs = [int64][math]::Round(($endedUtc - $startedUtc).TotalMilliseconds, 0) & $writeReport $operation Write-Verbose ("Pipeline timing completed: pipeline='{0}', step={1}, name='{2}', status={3}, durationMs={4}." -f $PipelineName, $StepNumber, $StepName, $operation.status, $operation.durationMs) if ($operation.status -eq 'Succeeded') { # GitHub's pwsh wrapper exits with the final native exit code. A # workload may intentionally handle an az failure and still return # successfully; do not let that stale code fail the completed step. $global:LASTEXITCODE = 0 } } } |