Skip to content
quarry Artifact [3b155ab35b]
Login

Artifact [3b155ab35b]

Artifact 3b155ab35b30a5c13a6613d2852778654e45dfaa657443135f4cb90193bf255c:


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
}