← Files Power BI DesktopARCHIVED FILE
scripts/New-PowerBIKpiIncidentReport.ps1
10.8 KB · Oct 2, 2026 · 00:32 UTC
param(
[string]$Path = ".",
[string]$MeasureName,
[string]$BeforePath,
[string]$AfterPath,
[string]$OutputPath,
[switch]$Json
)
$ErrorActionPreference = 'Stop'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = (Resolve-Path -LiteralPath $Path).Path
function Invoke-JsonScript {
param([string]$ScriptName, [hashtable]$Arguments)
$scriptPath = Join-Path $scriptRoot $ScriptName
$cmdArgs = @{}
foreach ($key in $Arguments.Keys) {
if ($null -ne $Arguments[$key] -and $Arguments[$key] -ne '') {
$cmdArgs[$key] = $Arguments[$key]
}
}
& $scriptPath @cmdArgs -Json | ConvertFrom-Json
}
function Add-TimelineEvent {
param(
[System.Collections.Generic.List[object]]$Timeline,
[string]$Stage,
[string]$Signal,
[string]$Detail
)
$Timeline.Add([pscustomobject]@{
timestamp = (Get-Date).ToString('s')
stage = $Stage
signal = $Signal
detail = $Detail
})
}
function Get-MetricCatalogWithLiveFallback {
param([string]$ProjectPath, [string]$RootPath)
$catalogResult = Invoke-JsonScript -ScriptName 'New-PowerBIMetricCatalog.ps1' -Arguments @{ Path = $ProjectPath }
if (@($catalogResult.metrics).Count -gt 0) { return $catalogResult }
$liveCatalogPath = Join-Path $RootPath 'live-metric-catalog.json'
if (-not (Test-Path -LiteralPath $liveCatalogPath)) { return $catalogResult }
$liveCatalog = Get-Content -Raw -LiteralPath $liveCatalogPath | ConvertFrom-Json
[pscustomobject]@{
schema = 'codex.powerbi.metricCatalog.liveCatalogFallback.v1'
root = $RootPath
metricCount = @($liveCatalog.metrics).Count
metrics = @($liveCatalog.metrics | ForEach-Object {
[pscustomobject]@{
id = $_.id
name = $_.name
table = $_.table
source = $_.source
expression = $_.expression
risks = @($_.risks)
}
})
}
}
$catalog = Get-MetricCatalogWithLiveFallback -ProjectPath $Path -RootPath $root
$trust = Invoke-JsonScript -ScriptName 'New-PowerBIKpiTrustScore.ps1' -Arguments @{ Path = $Path }
$rootCauseGraph = Invoke-JsonScript -ScriptName 'New-PowerBIBrokenMeasureRootCauseGraph.ps1' -Arguments @{ Path = $Path }
$flightRecorder = $null
try {
$flightRecorder = Invoke-JsonScript -ScriptName 'New-PowerBIFlightRecorder.ps1' -Arguments @{ Path = $Path }
}
catch {
$flightRecorder = [pscustomobject]@{ schema = 'codex.powerbi.flightRecorder.unavailable'; error = $_.Exception.Message }
}
$comparison = $null
if ($BeforePath -and $AfterPath) {
try {
$comparison = Invoke-JsonScript -ScriptName 'Compare-PowerBIMeasureBehavior.ps1' -Arguments @{ Path = $Path; BeforePath = $BeforePath; AfterPath = $AfterPath }
}
catch {
$comparison = [pscustomobject]@{ schema = 'codex.powerbi.measureBehaviorComparison.unavailable'; status = 'Unavailable'; error = $_.Exception.Message; comparisons = @() }
}
}
$selectedMetrics = @($catalog.metrics)
if ($MeasureName) {
$selectedMetrics = @($selectedMetrics | Where-Object { $_.name -eq $MeasureName -or $_.id -eq $MeasureName })
}
if (-not $MeasureName) {
$selectedNames = @(
@($trust.metrics | Where-Object { $_.trustScore -lt 80 } | Select-Object -ExpandProperty name)
@($rootCauseGraph.rootCauses | Select-Object -ExpandProperty measure)
@($comparison.comparisons | Where-Object { $_.status -ne 'Passed' } | ForEach-Object { if ($_.measure) { $_.measure } elseif ($_.id) { ($_.id -split '\.')[-1] -replace '-', ' ' } })
) | Where-Object { $_ } | Sort-Object -Unique
if ($selectedNames.Count -gt 0) {
$selectedMetrics = @($catalog.metrics | Where-Object { $_.name -in $selectedNames })
}
}
$timeline = New-Object System.Collections.Generic.List[object]
Add-TimelineEvent -Timeline $timeline -Stage 'Catalog' -Signal 'MetricCatalog' -Detail ('Cataloged {0} measures from {1}.' -f $catalog.metricCount, $catalog.root)
Add-TimelineEvent -Timeline $timeline -Stage 'Trust' -Signal 'KpiTrustScore' -Detail ('Overall trust score {0}; selected affected measures {1}.' -f $trust.overallTrustScore, @($selectedMetrics).Count)
Add-TimelineEvent -Timeline $timeline -Stage 'RootCause' -Signal 'BrokenMeasureRootCauseGraph' -Detail ('Root cause candidates {0}.' -f $rootCauseGraph.rootCauseCount)
if ($flightRecorder.latest) {
Add-TimelineEvent -Timeline $timeline -Stage 'History' -Signal 'FlightRecorder' -Detail ('Latest recorded score {0}, decision {1}, trend {2}.' -f $flightRecorder.latest.overallTrustScore, $flightRecorder.latest.releaseDecision, $flightRecorder.trend)
}
elseif ($flightRecorder.error) {
Add-TimelineEvent -Timeline $timeline -Stage 'History' -Signal 'FlightRecorderUnavailable' -Detail $flightRecorder.error
}
if ($comparison) {
Add-TimelineEvent -Timeline $timeline -Stage 'Behavior' -Signal 'MeasureBehaviorComparison' -Detail ('Status {0}; comparisons {1}; failed {2}; not available {3}.' -f $comparison.status, $comparison.comparisonCount, $comparison.failedCount, $comparison.notAvailableCount)
}
$affectedMeasures = foreach ($metric in @($selectedMetrics)) {
$trustItem = @($trust.metrics | Where-Object { $_.name -eq $metric.name } | Select-Object -First 1)
$rootCauses = @($rootCauseGraph.rootCauses | Where-Object { $_.measure -eq $metric.name })
$comparisons = @($comparison.comparisons | Where-Object { $_.measure -eq $metric.name -or $_.id -match [regex]::Escape($metric.id) })
[pscustomobject]@{
name = $metric.name
table = $metric.table
source = $metric.source
trustScore = if ($trustItem) { $trustItem.trustScore } else { $null }
risks = @($metric.risks)
rootCauseCount = $rootCauses.Count
comparisonStatuses = @($comparisons | ForEach-Object { $_.status } | Sort-Object -Unique)
}
}
$probableRootCauses = foreach ($measure in @($affectedMeasures)) {
$graphItems = @($rootCauseGraph.rootCauses | Where-Object { $_.measure -eq $measure.name })
if ($graphItems.Count -gt 0) {
foreach ($item in $graphItems) {
[pscustomobject]@{
measure = $measure.name
cause = $item.rootCause
confidence = if (@($item.risks).Count -gt 0) { 'High' } else { 'Medium' }
evidence = @(@($item.risks) + @($measure.comparisonStatuses | ForEach-Object { "comparisonStatus:$_" }))
fixOrder = $item.fixOrder
}
}
}
else {
[pscustomobject]@{
measure = $measure.name
cause = if (@($measure.risks).Count -gt 0) { 'DAX risk requires manual review.' } elseif ($measure.trustScore -lt 80) { 'Trust score deductions require owner and validation review.' } else { 'No deterministic root cause found from offline signals.' }
confidence = if (@($measure.risks).Count -gt 0 -or $measure.trustScore -lt 80) { 'Medium' } else { 'Low' }
evidence = @(@($measure.risks) + @("trustScore:$($measure.trustScore)"))
fixOrder = 'Review locally before downstream validation.'
}
}
}
$rollbackGuidance = @(
'Do not mutate PBIX/PBIT binaries from this report.',
'If behavior validation fails, restore the previous DAX expression from source control or the before PBIP/TMDL model.',
'Rerun MetricCatalog, KPI Trust Score, BrokenMeasureRootCauseGraph, and measure behavior comparison before release sign-off.'
)
if ($comparison -and $comparison.status -eq 'Failed') {
$rollbackGuidance += 'Failed comparison rows should be treated as rollback candidates unless business owners approve the variance.'
}
$validationPlan = @(
'Run New-PowerBIMetricCatalog.ps1 to confirm affected measures are still discoverable.',
'Run New-PowerBIKpiTrustScore.ps1 and confirm affected measure trust scores are 80 or higher, or document an explicit waiver.',
'Run New-PowerBIBrokenMeasureRootCauseGraph.ps1 to verify no high-confidence root cause remains open.',
'Run Compare-PowerBIMeasureBehavior.ps1 with before/after paths or baseline/current result files for value-level validation.'
)
$incidentIdSource = '{0}|{1}|{2}' -f $root, ($MeasureName -as [string]), (Get-Date).ToString('yyyyMMddHHmmss')
$incidentHash = [System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::Create().ComputeHash([System.Text.Encoding]::UTF8.GetBytes($incidentIdSource))).Replace('-', '').Substring(0, 12).ToLowerInvariant()
$result = [pscustomobject]@{
schema = 'codex.powerbi.kpiIncidentReport.v1'
incidentId = "pbi-kpi-$incidentHash"
root = $root
generated = (Get-Date).ToString('s')
measureName = $MeasureName
comparisonStatus = if ($comparison) { $comparison.status } else { 'NotRequested' }
affectedMeasures = @($affectedMeasures)
evidenceTimeline = @($timeline.ToArray())
probableRootCauses = @($probableRootCauses)
rollbackGuidance = @($rollbackGuidance)
validationPlan = @($validationPlan)
}
if ($Json) {
$text = $result | ConvertTo-Json -Depth 12
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $text -Encoding UTF8 }
$text
return
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('# Power BI KPI Incident Report')
$lines.Add('')
$lines.Add(('Schema: `{0}`' -f $result.schema))
$lines.Add(('Incident ID: `{0}`' -f $result.incidentId))
$lines.Add(('Root: `{0}`' -f $result.root))
$lines.Add(('Generated: {0}' -f $result.generated))
$lines.Add(('Comparison status: {0}' -f $result.comparisonStatus))
$lines.Add('')
$lines.Add('## Affected Measures')
$lines.Add('')
foreach ($measure in $result.affectedMeasures) {
$lines.Add(('- `{0}` trust={1}; risks={2}; root causes={3}' -f $measure.name, $measure.trustScore, ((@($measure.risks) -join '; ')), $measure.rootCauseCount))
}
$lines.Add('')
$lines.Add('## Evidence Timeline')
$lines.Add('')
foreach ($event in $result.evidenceTimeline) {
$lines.Add(('- [{0}] {1}: {2}' -f $event.stage, $event.signal, $event.detail))
}
$lines.Add('')
$lines.Add('## Probable Root Causes')
$lines.Add('')
foreach ($cause in $result.probableRootCauses) {
$lines.Add(('- `{0}`: {1} ({2}) - {3}' -f $cause.measure, $cause.cause, $cause.confidence, $cause.fixOrder))
}
$lines.Add('')
$lines.Add('## Rollback Guidance')
$lines.Add('')
foreach ($item in $result.rollbackGuidance) { $lines.Add(('- {0}' -f $item)) }
$lines.Add('')
$lines.Add('## Validation Plan')
$lines.Add('')
foreach ($item in $result.validationPlan) { $lines.Add(('- {0}' -f $item)) }
$lines.Add('')
$content = ($lines -join [Environment]::NewLine) + [Environment]::NewLine
if ($OutputPath) { Set-Content -LiteralPath $OutputPath -Value $content -Encoding UTF8 }
$content
SHA-256: 1c0761f25464465c8454a39a61a60df8d580fdb873607d55598d3655480349ad