258 lines
7.9 KiB
Go
258 lines
7.9 KiB
Go
package runtimestats
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
)
|
|
|
|
// Health is the answer the Process card exists to give: is the gateway healthy, and if
|
|
// something is abnormal, which area should be looked at?
|
|
//
|
|
// It is stated on the server rather than derived in the console for the same reason every
|
|
// schedule label is the gateway's wording — the thresholds and the sentence explaining
|
|
// them belong together, and an older console must not be the thing deciding what "watch"
|
|
// means. It is a pure function of a snapshot so the thresholds can be tested rather than
|
|
// discovered in production.
|
|
type Health struct {
|
|
Level Level `json:"level"`
|
|
Summary string `json:"summary"`
|
|
Notes []Note `json:"notes"`
|
|
Areas []string `json:"areas,omitempty"`
|
|
}
|
|
|
|
type Level string
|
|
|
|
const (
|
|
// LevelOK is nothing to do. LevelWatch is something that is not wrong yet and would
|
|
// be if it continued — which is the whole reason trends are collected. LevelBad is
|
|
// something an operator should act on now.
|
|
LevelOK Level = "ok"
|
|
LevelWatch Level = "watch"
|
|
LevelBad Level = "bad"
|
|
)
|
|
|
|
type Note struct {
|
|
Level Level `json:"level"`
|
|
// Area names the part of Memby to look at. It is the note's most useful field: a
|
|
// number that has moved is only actionable once it points somewhere.
|
|
Area string `json:"area"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// The thresholds. Heap is judged against the configured limit because that is the number
|
|
// the container is actually killed at; the watch line is deliberately well below it, since
|
|
// the point of a watch is to arrive before the incident rather than during it.
|
|
const (
|
|
heapShareBad = 0.90
|
|
heapShareWatch = 0.75
|
|
// goroutineLeakPerHour is what separates a gateway that has picked up work from one
|
|
// that is not letting go of it. A household arriving home adds goroutines and gives
|
|
// them back; a leak does not, so this is only ever consulted alongside a rising trend
|
|
// measured over the trend window.
|
|
goroutineLeakPerHour = 10
|
|
goroutineFloodPerHour = 60
|
|
// pauseWatchMs is a collection pause long enough for a television to notice. The
|
|
// gateway answers a status poll every ten seconds from every open set.
|
|
pauseWatchMs = 50
|
|
pauseBadMs = 250
|
|
// restartsWatch: a worker that has been launched this many times is one that keeps
|
|
// dying. The registry counts starts for exactly this.
|
|
restartsWatch = 3
|
|
)
|
|
|
|
func assess(snapshot Snapshot) Health {
|
|
var notes []Note
|
|
|
|
memory := snapshot.Memory
|
|
switch {
|
|
case memory.HeapShare >= heapShareBad:
|
|
notes = append(notes, Note{
|
|
Level: LevelBad, Area: "Memory",
|
|
Message: fmt.Sprintf(
|
|
"The heap is using %s of the %s limit. Past the limit the container is stopped, so this needs attention now.",
|
|
percent(memory.HeapShare), bytes(uint64(memory.MemoryLimit)),
|
|
),
|
|
})
|
|
case memory.HeapShare >= heapShareWatch:
|
|
notes = append(notes, Note{
|
|
Level: LevelWatch, Area: "Memory",
|
|
Message: fmt.Sprintf(
|
|
"The heap is using %s of the %s limit. There is room, but less than usual.",
|
|
percent(memory.HeapShare), bytes(uint64(memory.MemoryLimit)),
|
|
),
|
|
})
|
|
}
|
|
|
|
if snapshot.HeapTrend.Direction == DirectionRising {
|
|
notes = append(notes, Note{
|
|
Level: LevelWatch, Area: "Memory",
|
|
Message: fmt.Sprintf(
|
|
"Heap in use has risen by about %s an hour over the last %s. If it does not fall after a collection, something is holding on to it.",
|
|
bytes(uint64(math.Max(0, snapshot.HeapTrend.PerHour))), duration(snapshot.HeapTrend.SpanSeconds),
|
|
),
|
|
})
|
|
}
|
|
|
|
if snapshot.GoroutineTrend.Direction == DirectionRising {
|
|
rate := snapshot.GoroutineTrend.PerHour
|
|
level := LevelOK
|
|
switch {
|
|
case rate >= goroutineFloodPerHour:
|
|
level = LevelBad
|
|
case rate >= goroutineLeakPerHour:
|
|
level = LevelWatch
|
|
}
|
|
if level != LevelOK {
|
|
notes = append(notes, Note{
|
|
Level: level, Area: "Goroutines",
|
|
Message: fmt.Sprintf(
|
|
"Goroutines have risen by about %.0f an hour over the last %s, from %.0f to %.0f. Open the breakdown to see which component they belong to.",
|
|
rate, duration(snapshot.GoroutineTrend.SpanSeconds),
|
|
snapshot.GoroutineTrend.First, snapshot.GoroutineTrend.Latest,
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case memory.PauseRecentMs >= pauseBadMs:
|
|
notes = append(notes, Note{
|
|
Level: LevelBad, Area: "Collection",
|
|
Message: fmt.Sprintf(
|
|
"Recent collections have paused the gateway for %.0f ms on average. Everything the televisions ask for waits that long.",
|
|
memory.PauseRecentMs,
|
|
),
|
|
})
|
|
case memory.PauseRecentMs >= pauseWatchMs:
|
|
notes = append(notes, Note{
|
|
Level: LevelWatch, Area: "Collection",
|
|
Message: fmt.Sprintf(
|
|
"Recent collections have paused the gateway for %.0f ms on average.", memory.PauseRecentMs,
|
|
),
|
|
})
|
|
}
|
|
|
|
for _, worker := range snapshot.Workers {
|
|
if worker.State == WorkerRunning && worker.Starts >= restartsWatch {
|
|
notes = append(notes, Note{
|
|
Level: LevelWatch, Area: worker.Component,
|
|
Message: fmt.Sprintf(
|
|
"%s has been started %d times. A background worker that keeps restarting is failing at something.",
|
|
worker.Name, worker.Starts,
|
|
),
|
|
})
|
|
}
|
|
}
|
|
|
|
return Health{
|
|
Level: worst(notes),
|
|
Summary: summarise(snapshot, notes),
|
|
Notes: notes,
|
|
Areas: areasOf(notes),
|
|
}
|
|
}
|
|
|
|
func worst(notes []Note) Level {
|
|
level := LevelOK
|
|
for _, note := range notes {
|
|
if note.Level == LevelBad {
|
|
return LevelBad
|
|
}
|
|
if note.Level == LevelWatch {
|
|
level = LevelWatch
|
|
}
|
|
}
|
|
return level
|
|
}
|
|
|
|
// summarise is the one line the overview card prints. When there is nothing to say it says
|
|
// so plainly rather than reciting figures the operator can already see above it — and it
|
|
// says whether the verdict is based on enough history to be worth anything, because "no
|
|
// trouble" from four minutes of readings is a weaker claim than the same words from four
|
|
// hours.
|
|
func summarise(snapshot Snapshot, notes []Note) string {
|
|
if len(notes) == 0 {
|
|
if snapshot.GoroutineTrend.Direction == DirectionUnknown {
|
|
return "Nothing abnormal. Still gathering history — trends need about fifteen minutes."
|
|
}
|
|
return fmt.Sprintf(
|
|
"Nothing abnormal. Goroutines and memory have been %s over the last %s.",
|
|
steadyWord(snapshot), duration(snapshot.GoroutineTrend.SpanSeconds),
|
|
)
|
|
}
|
|
areas := areasOf(notes)
|
|
if len(notes) == 1 {
|
|
return notes[0].Message
|
|
}
|
|
return fmt.Sprintf("%d things to look at, in %s.", len(notes), joinAreas(areas))
|
|
}
|
|
|
|
func steadyWord(snapshot Snapshot) string {
|
|
if snapshot.GoroutineTrend.Direction == DirectionFalling || snapshot.HeapTrend.Direction == DirectionFalling {
|
|
return "steady or falling"
|
|
}
|
|
return "steady"
|
|
}
|
|
|
|
func areasOf(notes []Note) []string {
|
|
seen := map[string]bool{}
|
|
var areas []string
|
|
for _, note := range notes {
|
|
if note.Area == "" || seen[note.Area] {
|
|
continue
|
|
}
|
|
seen[note.Area] = true
|
|
areas = append(areas, note.Area)
|
|
}
|
|
sort.Strings(areas)
|
|
return areas
|
|
}
|
|
|
|
func joinAreas(areas []string) string {
|
|
switch len(areas) {
|
|
case 0:
|
|
return "the gateway"
|
|
case 1:
|
|
return areas[0]
|
|
case 2:
|
|
return areas[0] + " and " + areas[1]
|
|
}
|
|
return fmt.Sprintf("%s and %d other areas", areas[0], len(areas)-1)
|
|
}
|
|
|
|
/* ---------- wording helpers ----------
|
|
|
|
These exist because the sentences above are the gateway's own wording, the stance every
|
|
label the console prints takes: a console built before a threshold existed still reads
|
|
correctly, and the figure in the sentence cannot disagree with the threshold that
|
|
produced it. */
|
|
|
|
func percent(share float64) string {
|
|
return fmt.Sprintf("%.1f%%", share*100)
|
|
}
|
|
|
|
func bytes(value uint64) string {
|
|
const unit = 1024
|
|
if value < unit {
|
|
return fmt.Sprintf("%d B", value)
|
|
}
|
|
div, exponent := uint64(unit), 0
|
|
for size := value / unit; size >= unit; size /= unit {
|
|
div *= unit
|
|
exponent++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(value)/float64(div), "KMGT"[exponent])
|
|
}
|
|
|
|
func duration(seconds float64) string {
|
|
switch {
|
|
case seconds < 90:
|
|
return fmt.Sprintf("%.0f seconds", seconds)
|
|
case seconds < 5400:
|
|
return fmt.Sprintf("%.0f minutes", seconds/60)
|
|
default:
|
|
return fmt.Sprintf("%.1f hours", seconds/3600)
|
|
}
|
|
}
|