package obs
import (
"cmp"
"slices"
"time"
)
// series.go turns the rings into the shapes a card can draw.
//
// A number on the ops page answers "how many right now"; the shape
// beside it answers "is that unusual", which is the question the number
// provokes and cannot settle. Both come from the rings already kept for
// the panels below, so nothing here samples, stores or schedules.
// Bins is how many points a card's line carries. Small on purpose: the
// sparkline is a glance, and a point per pixel is a chart.
const Bins = 24
// SeriesWindow is how far back a card's line reaches. Three minutes is
// what an operator watching a deploy or a sync actually wants: long
// enough to show a spike rising and falling, short enough that the
// line is about NOW rather than about this morning.
const SeriesWindow = 3 * time.Minute
// ConcurrencyBins samples how many subprocesses were RUNNING at the
// start of each bin across the window ending at now.
//
// Running, not started: the number above the line counts what is in
// flight, and a line counting starts would answer a different question
// under the same heading — five running drawn over a bar of twelve.
// A call is in flight during [At, At+Duration).
func ConcurrencyBins(subs []Subprocess, now time.Time, window time.Duration, bins int) []int {
if bins <= 0 || window <= 0 {
return nil
}
out := make([]int, bins)
step := window / time.Duration(bins)
start := now.Add(-window)
for i := range out {
at := start.Add(time.Duration(i) * step)
for _, s := range subs {
if !s.At.After(at) && s.At.Add(s.Duration).After(at) {
out[i]++
}
}
}
return out
}
// CountBins counts events per bin across the window ending at now.
// Anything before the window is dropped rather than piled into the
// first bin, which would draw a spike that never happened.
func CountBins(ats []time.Time, now time.Time, window time.Duration, bins int) []int {
if bins <= 0 || window <= 0 {
return nil
}
out := make([]int, bins)
step := window / time.Duration(bins)
start := now.Add(-window)
for _, at := range ats {
if at.Before(start) || at.After(now) {
continue
}
i := int(at.Sub(start) / step)
if i >= bins {
i = bins - 1 // the instant now lands in the last bin, not past the end
}
if i >= 0 {
out[i]++
}
}
return out
}
// RenderTimes is when each render in the ring began.
func (s Snapshot) RenderTimes() []time.Time {
out := make([]time.Time, 0, len(s.Renders))
for _, r := range s.Renders {
out = append(out, r.At)
}
return out
}
// Attribution is where one verb's calls came from: the repositories
// they ran against and what asked for them.
type Attribution struct {
Verb string
// Repos and Causes are the top few, most calls first. A cause is
// either a background activity ("warm", "sync") or the route of the
// render that asked, so the two namespaces never collide: one is a
// word, the other starts with a method.
Repos []Tally
Causes []Tally
}
// Tally is one name and how many calls carried it.
type Tally struct {
Name string
Count int
}
// AttributeVerbs answers "what asked for these" for every verb in the
// window, keeping the top few of each so a panel row stays a row.
//
// routeOf maps a render id to the route that render served; a render
// that has fallen out of the ring resolves to nothing and the call is
// counted as unattributed rather than guessed at.
func AttributeVerbs(subs []Subprocess, routeOf func(renderID string) string, top int) []Attribution {
byVerb := map[string]*Attribution{}
repos := map[string]map[string]int{}
causes := map[string]map[string]int{}
for _, s := range subs {
a, ok := byVerb[s.Verb]
if !ok {
a = &Attribution{Verb: s.Verb}
byVerb[s.Verb] = a
repos[s.Verb] = map[string]int{}
causes[s.Verb] = map[string]int{}
}
_ = a
if s.Repo != "" {
repos[s.Verb][s.Repo]++
}
switch {
case s.Reason != "":
causes[s.Verb][s.Reason]++
case s.RenderID != "" && routeOf != nil:
if r := routeOf(s.RenderID); r != "" {
causes[s.Verb][r]++
}
}
}
out := make([]Attribution, 0, len(byVerb))
for verb, a := range byVerb {
a.Repos = topTallies(repos[verb], top)
a.Causes = topTallies(causes[verb], top)
out = append(out, *a)
}
slices.SortFunc(out, func(x, y Attribution) int { return cmp.Compare(x.Verb, y.Verb) })
return out
}
func topTallies(m map[string]int, top int) []Tally {
if len(m) == 0 {
return nil
}
out := make([]Tally, 0, len(m))
for k, v := range m {
out = append(out, Tally{Name: k, Count: v})
}
// Most calls first; ties by name so the panel does not reshuffle
// between two refreshes that saw the same counts.
slices.SortFunc(out, func(a, b Tally) int {
if c := cmp.Compare(b.Count, a.Count); c != 0 {
return c
}
return cmp.Compare(a.Name, b.Name)
})
if top > 0 && len(out) > top {
out = out[:top]
}
return out
}