mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Performance reports answered 'what were the averages' but never 'was my infrastructure up' - the question a managed-service client reads a monthly report for. Reports now carry an Availability summary derived from the recorded resource change timeline (state_transition entries keyed by the canonical unified ID): - uptime percent over the observed portion of the window, outage count, total downtime, and longest outage, rendered in the executive summary with an explicit semantics note; fleet summaries gain a per-resource Uptime column and CSV exports gain availability header lines - absent/unknown spans are unobserved time: excluded from the uptime math entirely and disclosed as coverage, never counted as downtime. The journal records a registry absence for every monitor restart, so treating gaps as outages would invent fleet-wide downtime every time the operator restarts Pulse - warning states count as up (the resource is reachable and serving); the uptime label clamps rounding so any real downtime can never display as a clean 100% - resources with no timeline render no availability section at all rather than a fabricated number Verified live against a real 7-day window: uptime/outage/downtime figures reconcile with the raw resource_changes journal.
164 lines
5 KiB
Go
164 lines
5 KiB
Go
package api
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
|
)
|
|
|
|
// reportAvailabilityChangeLimit bounds how many timeline rows a single
|
|
// report subject pulls. Production resources record a handful of state
|
|
// transitions per month; a resource that exceeds this is flapping hard
|
|
// enough that the truncated window still tells the honest story.
|
|
const reportAvailabilityChangeLimit = 20000
|
|
|
|
// availabilityState classifies a recorded resource state for uptime math.
|
|
type availabilityState int
|
|
|
|
const (
|
|
availabilityStateUnobserved availabilityState = iota
|
|
availabilityStateUp
|
|
availabilityStateDown
|
|
)
|
|
|
|
// classifyAvailabilityState maps the canonical resource state vocabulary
|
|
// (online / warning / offline / unknown plus the synthetic "absent" emitted
|
|
// when a resource enters or leaves the registry) onto uptime semantics.
|
|
// Warning counts as up: the resource is reachable and serving, just
|
|
// unhealthy. Absent and unknown are unobserved - a monitoring gap is not an
|
|
// outage. Unrecognized future states default to up because a client-facing
|
|
// stability report claiming false downtime is worse than missing an exotic
|
|
// down state.
|
|
func classifyAvailabilityState(state string) availabilityState {
|
|
switch strings.ToLower(strings.TrimSpace(state)) {
|
|
case "", "absent", "unknown":
|
|
return availabilityStateUnobserved
|
|
case "offline":
|
|
return availabilityStateDown
|
|
default:
|
|
return availabilityStateUp
|
|
}
|
|
}
|
|
|
|
func availabilityChangeTime(change unifiedresources.ResourceChange) time.Time {
|
|
if change.OccurredAt != nil && !change.OccurredAt.IsZero() {
|
|
return *change.OccurredAt
|
|
}
|
|
return change.ObservedAt
|
|
}
|
|
|
|
// computeReportAvailability derives an availability summary for one report
|
|
// subject from its recorded state timeline. The walk reconstructs the state
|
|
// for every moment of [start, end]: the state before the first in-window
|
|
// transition is that transition's From; with no transitions at all the
|
|
// resource sat in currentState for the whole window (any change would have
|
|
// been journaled).
|
|
func computeReportAvailability(changes []unifiedresources.ResourceChange, currentState string, start, end time.Time) *reporting.AvailabilityInfo {
|
|
if !end.After(start) {
|
|
return nil
|
|
}
|
|
|
|
transitions := make([]unifiedresources.ResourceChange, 0, len(changes))
|
|
for _, change := range changes {
|
|
if change.Kind != unifiedresources.ChangeStateTransition {
|
|
continue
|
|
}
|
|
at := availabilityChangeTime(change)
|
|
if at.Before(start) || at.After(end) {
|
|
continue
|
|
}
|
|
transitions = append(transitions, change)
|
|
}
|
|
sort.SliceStable(transitions, func(i, j int) bool {
|
|
return availabilityChangeTime(transitions[i]).Before(availabilityChangeTime(transitions[j]))
|
|
})
|
|
|
|
initialState := currentState
|
|
if len(transitions) > 0 {
|
|
initialState = transitions[0].From
|
|
}
|
|
|
|
var up, down time.Duration
|
|
var downIncidents int
|
|
var longestOutage, currentOutage time.Duration
|
|
|
|
accumulate := func(state availabilityState, d time.Duration) {
|
|
if d <= 0 {
|
|
return
|
|
}
|
|
switch state {
|
|
case availabilityStateUp:
|
|
up += d
|
|
case availabilityStateDown:
|
|
down += d
|
|
currentOutage += d
|
|
}
|
|
if state != availabilityStateDown {
|
|
if currentOutage > longestOutage {
|
|
longestOutage = currentOutage
|
|
}
|
|
currentOutage = 0
|
|
}
|
|
}
|
|
|
|
cursor := start
|
|
state := classifyAvailabilityState(initialState)
|
|
for _, change := range transitions {
|
|
at := availabilityChangeTime(change)
|
|
accumulate(state, at.Sub(cursor))
|
|
next := classifyAvailabilityState(change.To)
|
|
if next == availabilityStateDown && state != availabilityStateDown {
|
|
downIncidents++
|
|
}
|
|
state = next
|
|
cursor = at
|
|
}
|
|
accumulate(state, end.Sub(cursor))
|
|
if currentOutage > longestOutage {
|
|
longestOutage = currentOutage
|
|
}
|
|
|
|
window := end.Sub(start)
|
|
observed := up + down
|
|
info := &reporting.AvailabilityInfo{
|
|
ObservedPercent: 100 * float64(observed) / float64(window),
|
|
TotalDowntime: down,
|
|
LongestOutage: longestOutage,
|
|
DownIncidents: downIncidents,
|
|
}
|
|
if observed > 0 {
|
|
info.UptimePercent = 100 * float64(up) / float64(observed)
|
|
}
|
|
return info
|
|
}
|
|
|
|
// resolveReportAvailability attaches the availability summary for the report
|
|
// subject. The change timeline is keyed by the canonical unified resource ID
|
|
// (not the metrics-target ID), so this runs against req.ResourceID.
|
|
func (h *ReportingHandlers) resolveReportAvailability(orgID string, req *reporting.MetricReportRequest, snapshot reportingEnrichmentSnapshot, start, end time.Time) {
|
|
if h == nil || req == nil || h.mtMonitor == nil {
|
|
return
|
|
}
|
|
|
|
currentState := ""
|
|
for i := range snapshot.Resources {
|
|
if snapshot.Resources[i].ID == req.ResourceID {
|
|
currentState = string(snapshot.Resources[i].Status)
|
|
break
|
|
}
|
|
}
|
|
if currentState == "" {
|
|
// Unknown to the unified registry: no timeline to report against.
|
|
return
|
|
}
|
|
|
|
monitor, err := h.mtMonitor.GetMonitor(orgID)
|
|
if err != nil || monitor == nil {
|
|
return
|
|
}
|
|
changes := monitor.RecentResourceChanges(req.ResourceID, start, reportAvailabilityChangeLimit)
|
|
req.Availability = computeReportAvailability(changes, currentState, start, end)
|
|
}
|