Private/EventPattern.ps1
|
# How the events a Finding counts are spread in time. # # 72 lost connections in one half hour, 60 refusals at 10:01 and 14:01 every day, 11 drops # over five days and a failure every minute for ten hours are four different causes, and the # count says none of them. The Judges that count events per Subject say the spread beside # the count, and every one of them says it with this, so that the words and the thresholds # are the same wherever a Technician reads them. Judged, not drawn (ADR-0002): the Report and # the views only show what this said. function Get-EventPatternThreshold { <# .SYNOPSIS The thresholds of the event patterns: those a Check Definition names, and the defaults where it names none. Pure. .DESCRIPTION Parameters of the Check Definitions of the Kinds that say a pattern (ADR-0001), so that a Second Level engineer tunes them without a module release. The defaults are those the prototype settled on a real Run. .PARAMETER Parameters The Check Definition's parameters, or the hashtable this returned before: missing keys take their defaults either way. #> [CmdletBinding()] [OutputType([hashtable])] param([AllowNull()][hashtable]$Parameters) @{ PatternMinimumEvents = [int](Get-Parameter -Parameters $Parameters -Name 'PatternMinimumEvents' -Default 5) PatternMinimumDays = [double](Get-Parameter -Parameters $Parameters -Name 'PatternMinimumDays' -Default 5) PatternBurstGapMinutes = [double](Get-Parameter -Parameters $Parameters -Name 'PatternBurstGapMinutes' -Default 30) PatternBurstShare = [double](Get-Parameter -Parameters $Parameters -Name 'PatternBurstShare' -Default 0.8) PatternBurstMaxMinutes = [double](Get-Parameter -Parameters $Parameters -Name 'PatternBurstMaxMinutes' -Default 120) PatternDailyMinimumDays = [int](Get-Parameter -Parameters $Parameters -Name 'PatternDailyMinimumDays' -Default 5) PatternDailyShare = [double](Get-Parameter -Parameters $Parameters -Name 'PatternDailyShare' -Default 0.4) PatternDailyMaxTimes = [int](Get-Parameter -Parameters $Parameters -Name 'PatternDailyMaxTimes' -Default 3) PatternIntervalMinimumEvents = [int](Get-Parameter -Parameters $Parameters -Name 'PatternIntervalMinimumEvents' -Default 7) PatternIntervalShare = [double](Get-Parameter -Parameters $Parameters -Name 'PatternIntervalShare' -Default 0.8) PatternEdgeDays = [double](Get-Parameter -Parameters $Parameters -Name 'PatternEdgeDays' -Default 2) } } function New-EventPatternPeriod { <# .SYNOPSIS The period a Kind's events are judged in, as Get-EventPattern takes it: from when the logs cover it, the time of the Run, its days and the thresholds. Nothing where the data does not say when the period began. Pure. .DESCRIPTION A log overwrites its oldest records, so one that reaches back a week covers a week of a month: "erst seit" said of its first event would be the start of the log, and a daily time on 5 of 30 days would be on 5 of 7. The log that reaches back least limits it. .PARAMETER Data A Gatherer's data with Days, Since and LogCoverage, as the NetworkDisruption Kind describes them. .PARAMETER Parameters The Check Definition's parameters: its Days where the data has none, and the thresholds (Get-EventPatternThreshold). .PARAMETER Log The logs the events were read from. Without it, every log the data covers. #> [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [AllowNull()][hashtable]$Parameters, [AllowNull()][AllowEmptyCollection()][string[]]$Log ) $since = ConvertTo-DataTime (Get-DataProperty $Data 'Since') if ($null -eq $since) { return } $days = Get-DataProperty $Data 'Days' if ($null -eq $days) { $days = Get-Parameter -Parameters $Parameters -Name 'Days' -Default 30 } $logs = @($Log | Where-Object { $_ }) $covered = $since foreach ($one in (Get-DataCollection $Data 'LogCoverage')) { if ($logs.Count -and $logs -notcontains "$(Get-DataProperty $one 'Log')") { continue } $oldest = ConvertTo-DataTime (Get-DataProperty $one 'Oldest') if ($null -ne $oldest -and $oldest -gt $covered) { $covered = $oldest } } [pscustomobject]@{ CoveredFrom = $covered RunAt = $since.AddDays([double]$days) Days = [int]$days Threshold = Get-EventPatternThreshold -Parameters $Parameters } } function Get-EventPattern { <# .SYNOPSIS How the events of one Subject and one kind of event are spread in time, as tokens and as the sentence a list item says behind its span: "Ausbruch: 102 von 102, 14.10. 22:12 bis 22:38", "täglich gegen 10:01 und 14:01, an 30 von 30 Tagen", "jede Minute, 14.10. 22:09 bis 15.10. 08:24, erst seit 14.10.", "verstreut". Pure. .DESCRIPTION Tokens, in the order they are said: burst, daily, interval, working-hours, onset, end, scattered. Scattered is what is left when there is no burst, no daily time and no interval: the spread was looked at and is irregular. Nothing is said rather than guessed: 'few' where there are too few events to call anything a trend, with no sentence, and 'short' where the logs cover too few days, with a sentence that says why - a two-day log is not to be read as a month. One thing is said once. A burst is not also an interval: a hundred events in half an hour are a minute apart because they are a burst. Nor does it have an onset or an end: its window says both. An interval is not also daily: events every 1.6 hours come back at the same times every day because they are an interval. Times are local. A job at 10:01 stays at 10:01 across the change of the clock. A job scheduled in UTC moves by an hour, out of the quarters beside its own, and is said as the two times it was run at. .PARAMETER Time When the events happened. Unsorted; what is no time is left out. .PARAMETER CoveredFrom From when the logs of these events cover the period: the later of the period's start and how far back the log reaches. An event at the start of a log is not the start of a fault, so an onset is only said after this. .PARAMETER RunAt When the period ends: the time of the Run. .PARAMETER Days The period's length in days. Tells a log that reaches back too little from a period that is short in itself. .PARAMETER Threshold As Get-EventPatternThreshold returns it; what it leaves out takes the default. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [AllowNull()][AllowEmptyCollection()][object[]]$Time, [Parameter(Mandatory)][datetime]$CoveredFrom, [Parameter(Mandatory)][datetime]$RunAt, [Parameter(Mandatory)][int]$Days, [AllowNull()][hashtable]$Threshold ) $limit = Get-EventPatternThreshold -Parameters $Threshold $list = [System.Collections.Generic.List[datetime]]::new() foreach ($value in @($Time)) { if ($null -eq $value) { continue } $at = ConvertTo-DataTime $value if ($at -is [datetime]) { $list.Add($at) } } if ($list.Count -lt $limit.PatternMinimumEvents) { return [pscustomobject]@{ Tokens = [string[]]@('few'); Text = '' } } # Covered days: from the later of the period's start and the log's reach, to the Run. $covered = ($RunAt - $CoveredFrom).TotalDays if ($covered -lt $limit.PatternMinimumDays) { $reach = [math]::Max(1, [int][math]::Round($covered)) $said = if ($reach -eq 1) { Get-Text 'Value.EventPattern.Days.One' } else { Get-Text 'Value.EventPattern.Days.Many' $reach } # The period itself is short where the log reaches back to its start; otherwise the # log is what is short. A minute either way is the same start. $text = if ($CoveredFrom -gt $RunAt.AddDays(-$Days).AddMinutes(1)) { Get-Text 'Value.EventPattern.Short.Log' $said } else { Get-Text 'Value.EventPattern.Short.Period' $said } return [pscustomobject]@{ Tokens = [string[]]@('short'); Text = $text } } $sorted = $list.ToArray() [array]::Sort($sorted) $count = $sorted.Count $first = $sorted[0] $last = $sorted[$count - 1] # Burst: events no further apart than the gap form clusters; the largest holds most of # them in a short window. $bestFrom = 0; $bestTo = 0; $from = 0 for ($i = 1; $i -le $count; $i++) { if ($i -eq $count -or ($sorted[$i] - $sorted[$i - 1]).TotalMinutes -gt $limit.PatternBurstGapMinutes) { if (($i - $from) -gt ($bestTo - $bestFrom + 1)) { $bestFrom = $from; $bestTo = $i - 1 } $from = $i } } $burstCount = $bestTo - $bestFrom + 1 $burst = $burstCount -ge ($limit.PatternBurstShare * $count) -and ($sorted[$bestTo] - $sorted[$bestFrom]).TotalMinutes -le $limit.PatternBurstMaxMinutes # Interval, on the events reduced to one per minute. The median gap and not the mean: six # longer pauses among 598 gaps of a minute moved the mean and hid a retry every minute. $interval = $false; $median = 0.0 if (-not $burst) { $minutes = [System.Collections.Generic.List[long]]::new() $previous = [long]-1 foreach ($at in $sorted) { $minute = [long][math]::Floor($at.Ticks / [timespan]::TicksPerMinute) if ($minute -ne $previous) { $minutes.Add($minute); $previous = $minute } } if ($minutes.Count -ge $limit.PatternIntervalMinimumEvents) { $gaps = [double[]]@(for ($i = 1; $i -lt $minutes.Count; $i++) { $minutes[$i] - $minutes[$i - 1] }) $byLength = [double[]]$gaps.Clone() [array]::Sort($byLength) $middle = [int][math]::Floor($byLength.Count / 2) $median = if ($byLength.Count % 2) { $byLength[$middle] } else { ($byLength[$middle - 1] + $byLength[$middle]) / 2 } $within = [math]::Max(0.5, $median * 0.25) $alike = @($gaps | Where-Object { [math]::Abs($_ - $median) -le $within }).Count $interval = $median -lt 1440 -and ($alike / $gaps.Count) -ge $limit.PatternIntervalShare } } # Daily: quarter hours of the day, each with the one either side, round across midnight. # The quarter with events on the most days is a daily time when those are enough days and # enough of the days with events left; then again without its events, up to the most # times there are. A job at 10:01 lies in the quarters either side of its own on every # day, and a job an hour off lies outside them. # An interval of a few hours comes back at the same times every day because it is an # interval, and is said as one. One the day holds no more often than there are daily # times - 10:01 and 22:01 - is a job of the day, and daily says when. $daily = @() if (-not $interval -or $median -ge 1440 / [math]::Max(1, $limit.PatternDailyMaxTimes)) { $remaining = @(foreach ($at in $sorted) { $ofDay = $at.Hour * 60 + $at.Minute [pscustomobject]@{ Minute = $ofDay; Quarter = [int][math]::Floor($ofDay / 15); Day = $at.ToString('yyyyMMdd') } }) for ($round = 0; $round -lt $limit.PatternDailyMaxTimes -and $remaining.Count; $round++) { $daysIn = @{} foreach ($one in $remaining) { if (-not $daysIn.ContainsKey($one.Quarter)) { $daysIn[$one.Quarter] = @{} } $daysIn[$one.Quarter][$one.Day] = $true } $withEvents = @($remaining | ForEach-Object { $_.Day } | Select-Object -Unique).Count $best = -1; $bestDays = 0; $bestOwn = 0 foreach ($quarter in 0..95) { $union = @{} foreach ($near in @((($quarter + 95) % 96), $quarter, (($quarter + 1) % 96))) { if ($daysIn.ContainsKey($near)) { foreach ($day in $daysIn[$near].Keys) { $union[$day] = $true } } } $own = if ($daysIn.ContainsKey($quarter)) { $daysIn[$quarter].Count } else { 0 } if ($union.Count -gt $bestDays -or ($union.Count -eq $bestDays -and $own -gt $bestOwn)) { $best = $quarter; $bestDays = $union.Count; $bestOwn = $own } } if ($best -lt 0 -or $bestDays -lt $limit.PatternDailyMinimumDays -or $bestDays -lt ($limit.PatternDailyShare * $withEvents)) { break } $window = @((($best + 95) % 96), $best, (($best + 1) % 96)) $members = @($remaining | Where-Object { $window -contains $_.Quarter }) $remaining = @($remaining | Where-Object { $window -notcontains $_.Quarter }) # The median minute, measured from the middle of its quarter so that a time just # before midnight and one just after are minutes apart and not a day. $centre = $best * 15 + 7.5 $offsets = [double[]]@($members | ForEach-Object { ((($_.Minute - $centre + 720) % 1440) + 1440) % 1440 - 720 }) [array]::Sort($offsets) $said = [int]((([math]::Round($centre + $offsets[[int][math]::Floor(($offsets.Count - 1) / 2)]) % 1440) + 1440) % 1440) $daily += [pscustomobject]@{ Minute = $said; Days = $bestDays } } # One thing is said once: the times of the day, not the gap between them as well. if ($daily.Count) { $interval = $false } } $tokens = @() $parts = @() $span = Format-FindingSpan -First $first -Last $last if ($burst) { $tokens += 'burst' $parts += Get-Text 'Value.EventPattern.Burst' $burstCount $count (Format-FindingSpan -First $sorted[$bestFrom] -Last $sorted[$bestTo]) } if ($daily.Count) { $tokens += 'daily' $clock = @($daily | Sort-Object Minute | ForEach-Object { '{0:00}:{1:00}' -f [math]::Floor($_.Minute / 60), ($_.Minute % 60) }) $times = if ($clock.Count -eq 1) { $clock[0] } else { Get-Text 'Value.EventPattern.And' (($clock[0..($clock.Count - 2)]) -join ', ') $clock[-1] } # Of the covered days, never fewer than the days it was seen on. $of = [math]::Max($daily[0].Days, [int][math]::Round($covered)) $parts += Get-Text 'Value.EventPattern.Daily' $times $parts += Get-Text 'Value.EventPattern.DailyDays' $daily[0].Days $of } if ($interval) { $tokens += 'interval' $every = [int][math]::Round($median) $parts += if ($median -le 10) { # A few minutes or less is a machine retrying, said as the loop it is. if ($every -le 1) { Get-Text 'Value.EventPattern.Interval.Minute' $span } else { Get-Text 'Value.EventPattern.Interval.Minutes' $every $span } } elseif ($median -lt 60) { Get-Text 'Value.EventPattern.Interval.RegularMinutes' $every $span } else { $hours = $median / 60 if ([math]::Round($hours, 1) -eq 1) { Get-Text 'Value.EventPattern.Interval.Hour' $span } else { Get-Text 'Value.EventPattern.Interval.Hours' $hours.ToString('0.#', [cultureinfo]::GetCultureInfo('de-DE')) $span } } } # Scattered is said with the shapes, first of what is said beside it: "verstreut, erst # seit 10.10." reads as the spread and then when. if (-not $burst -and -not $daily.Count -and -not $interval) { $tokens += 'scattered' $parts += Get-Text 'Value.EventPattern.Scattered' } # Working hours: on several days, and none on a weekend or outside the day's work. $onDays = @($sorted | ForEach-Object { $_.Date } | Select-Object -Unique).Count $outside = @($sorted | Where-Object { $_.DayOfWeek -eq [DayOfWeek]::Saturday -or $_.DayOfWeek -eq [DayOfWeek]::Sunday -or $_.Hour -lt 7 -or $_.Hour -ge 19 }).Count if ($onDays -ge 3 -and -not $outside) { $tokens += 'working-hours' $parts += Get-Text 'Value.EventPattern.WorkingHours' } if (-not $burst) { if ($first -ge $CoveredFrom.AddDays($limit.PatternEdgeDays)) { $tokens += 'onset' $parts += Get-Text 'Value.EventPattern.Onset' $first } if ($last -le $RunAt.AddDays(-$limit.PatternEdgeDays)) { $tokens += 'end' $parts += Get-Text 'Value.EventPattern.End' $last } } [pscustomobject]@{ Tokens = [string[]]$tokens; Text = ($parts -join ', ') } } function Get-EventPatternByKind { <# .SYNOPSIS How the events of one Subject are spread in time, per kind of event, as its list item and its Subject part say it: "verstreut" where they are of one kind, "SMB-Verbindung getrennt: verstreut / <the timeouts>: Ausbruch: ..." where they are of several. Nothing where the period of a kind is not known. Pure. .DESCRIPTION Per kind, because a burst of timeouts and a month of scattered drops of one Server are two things, and blended they read as "verstreut". Events of one kind are said as Get-EventPattern words them. Events of several are each said with the kind in front, the kind with the most events first, and a kind too few to say anything of is left out: the kind stays in front of what is said, because several were counted. That the logs reach back too few days is said once, where that is all it says and it is the same for every kind. Each kind is judged in the period its own logs cover: a log that reaches back a week limits the pattern of its events, not of those another log holds. Tokens are those of every kind, each once, without 'few' and 'short': those say that nothing was said. .PARAMETER Row The events of one Subject, each with its kind and its time. .PARAMETER KindProperty The property that holds an event's kind. .PARAMETER TimeProperty The property that holds when it happened. .PARAMETER KindText A kind in words, as the Kind's Section names it: given the kind, returns the words. .PARAMETER Period The period a kind's events are judged in: given the kind, returns what New-EventPatternPeriod returns for the logs it is read from, or nothing where the data does not say when the period began. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [AllowNull()][AllowEmptyCollection()]$Row, [string]$KindProperty = 'Kind', [string]$TimeProperty = 'Time', [Parameter(Mandatory)][scriptblock]$KindText, [Parameter(Mandatory)][scriptblock]$Period ) $groups = @(@($Row | Where-Object { $_ }) | Group-Object { "$(Get-DataProperty $_ $KindProperty)" } | Sort-Object @{ Expression = 'Count'; Descending = $true }, Name) $spans = @(foreach ($group in $groups) { & $Period $group.Name }) if (@($spans | Where-Object { $null -ne $_ }).Count -lt $groups.Count) { return } $each = @(for ($i = 0; $i -lt $groups.Count; $i++) { $span = $spans[$i] $times = @($groups[$i].Group | ForEach-Object { ConvertTo-DataTime (Get-DataProperty $_ $TimeProperty) } | Where-Object { $null -ne $_ }) [pscustomobject]@{ Kind = $groups[$i].Name Pattern = Get-EventPattern -Time $times -CoveredFrom $span.CoveredFrom -RunAt $span.RunAt -Days $span.Days -Threshold $span.Threshold } }) $tokens = @($each | ForEach-Object { $_.Pattern.Tokens } | Where-Object { $_ -notin 'few', 'short' } | Select-Object -Unique) $spoken = @($each | Where-Object { "$($_.Pattern.Text)" }) $why = @($spoken | ForEach-Object { "$($_.Pattern.Text)" } | Select-Object -Unique) $text = '' if ($groups.Count -eq 1 -or (-not $tokens.Count -and $why.Count -eq 1)) { if ($spoken.Count) { $text = "$($spoken[0].Pattern.Text)" } } else { foreach ($one in $spoken) { $part = Get-Text 'Value.EventPattern.OfKind' "$(& $KindText $one.Kind)" "$($one.Pattern.Text)" $text = if ($text) { Get-Text 'Value.EventPattern.Between' $text $part } else { $part } } } [pscustomobject]@{ Tokens = [string[]]$tokens; Text = $text } } function Format-EventPatternItem { <# .SYNOPSIS A list item with the pattern of its events behind its span: "FS01 102x, 14.10. 22:12 bis 22:38 - Ausbruch: 102 von 102, 14.10. 22:12 bis 22:38". The item as it was where the pattern says nothing. Pure. .DESCRIPTION Its own part of the item, so that the Report shows it as a column beside the count and the span (Split-ReportTally). .PARAMETER Item The list item as it is written without it: what, how often and from when to when. .PARAMETER Pattern What Get-EventPattern returned for the events the item counts. #> [CmdletBinding()] [OutputType([string])] param( [AllowEmptyString()][string]$Item, [AllowNull()]$Pattern ) if ($null -eq $Pattern -or -not "$($Pattern.Text)") { return $Item } Get-Text 'Value.EventPattern.Item' $Item "$($Pattern.Text)" } |