509 lines
18 KiB
Go
509 lines
18 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package varz
|
|
|
|
import (
|
|
"io"
|
|
"math"
|
|
"runtime/metrics"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// runtimeMetricSpec describes one Go runtime/metrics metric that
|
|
// [Handler] exports in Prometheus format.
|
|
type runtimeMetricSpec struct {
|
|
// name is the runtime/metrics name, such as "/gc/heap/allocs:bytes".
|
|
name string
|
|
|
|
// typ is the Prometheus type: "gauge", "counter", or "histogram".
|
|
// Counters get a "_total" suffix, as in the Prometheus naming
|
|
// conventions.
|
|
typ string
|
|
|
|
// buckets says how a histogram's runtime buckets map to Prometheus
|
|
// buckets. It is required for histograms and ignored otherwise.
|
|
buckets histBuckets
|
|
|
|
// hidden means the metric is read (because it backs one of the
|
|
// legacy memstats_* metrics) but is not exported under its own
|
|
// go_runtime_ name.
|
|
hidden bool
|
|
}
|
|
|
|
// histBuckets says how the runtime's lower-bound-inclusive buckets
|
|
// [lo, hi) of a histogram become Prometheus's upper-bound-inclusive
|
|
// le buckets.
|
|
type histBuckets uint8
|
|
|
|
const (
|
|
// histTime is for the runtime's time histograms, which have
|
|
// hundreds of buckets. They are reduced as described at
|
|
// [runtimeHistMinBucket], and a runtime bucket [lo, hi) becomes
|
|
// le=hi, which is only wrong for a value exactly equal to hi.
|
|
histTime histBuckets = iota + 1
|
|
|
|
// histPowersOfTwo is for histograms whose observed values are all
|
|
// exactly equal to bucket lower bounds, such as goroutine stack
|
|
// sizes. A runtime bucket [lo, hi) becomes le=lo, so the labels
|
|
// name the values they count, and the runtime's final catch-all
|
|
// bucket is folded into +Inf.
|
|
histPowersOfTwo
|
|
|
|
// histInts is for histograms of small integer values, such as
|
|
// counts of GC cycles. A runtime bucket [lo, hi) becomes le=hi-1,
|
|
// which is exact for integers.
|
|
histInts
|
|
)
|
|
|
|
// runtimeMetricSpecs is the set of Go runtime/metrics metrics that
|
|
// [Handler] reads. Everything here is cheap for the runtime to read: no
|
|
// metric in this list stops the world or walks the heap or the
|
|
// goroutine list.
|
|
//
|
|
// The list is deliberately short. The hidden entries only back the
|
|
// legacy memstats_* metrics. The rest are either specific to the
|
|
// Tailscale fork of Go or have no runtime.MemStats equivalent and earn
|
|
// their place on a dashboard.
|
|
//
|
|
// Names that the running toolchain does not know (such as the
|
|
// /tailscale/ metrics when not built with the fork) are dropped on
|
|
// first use, so it is fine for this list to be a superset of what
|
|
// exists.
|
|
//
|
|
// Go's Cumulative metrics are declared as counters here and the rest
|
|
// as gauges; TestRuntimeMetricSpecs checks that against the
|
|
// runtime's own descriptions.
|
|
var runtimeMetricSpecs = []runtimeMetricSpec{
|
|
// These back the memstats_* metrics; see memstatsIndexes.
|
|
{name: "/memory/classes/heap/objects:bytes", typ: "gauge", hidden: true},
|
|
{name: "/gc/heap/allocs:bytes", typ: "counter", hidden: true},
|
|
{name: "/memory/classes/total:bytes", typ: "gauge", hidden: true},
|
|
{name: "/gc/heap/allocs:objects", typ: "counter", hidden: true},
|
|
{name: "/gc/heap/frees:objects", typ: "counter", hidden: true},
|
|
{name: "/gc/heap/tiny/allocs:objects", typ: "counter", hidden: true},
|
|
{name: "/gc/cycles/total:gc-cycles", typ: "counter", hidden: true},
|
|
{name: "/cpu/classes/gc/mark/idle:cpu-seconds", typ: "counter", hidden: true},
|
|
|
|
// GC CPU time as counters, so that rate() over a window works.
|
|
// memstats_gc_cpu_fraction, derived from these two (less the
|
|
// hidden idle mark time above), is a lifetime average and cannot
|
|
// show a recent change.
|
|
{name: "/cpu/classes/gc/total:cpu-seconds", typ: "counter"},
|
|
{name: "/cpu/classes/total:cpu-seconds", typ: "counter"},
|
|
|
|
// Live heap after the last GC. Unlike memstats_heap_alloc it
|
|
// excludes garbage not yet swept, so it is the number to compare
|
|
// against GOMEMLIMIT or a container memory limit.
|
|
{name: "/gc/heap/live:bytes", typ: "gauge"},
|
|
|
|
// How long runnable goroutines wait to be scheduled, and how long
|
|
// GC stop-the-world pauses take. Neither is visible any other way.
|
|
{name: "/sched/latencies:seconds", typ: "histogram", buckets: histTime},
|
|
{name: "/sched/pauses/total/gc:seconds", typ: "histogram", buckets: histTime},
|
|
|
|
// OS threads owned by the runtime, for spotting thread leaks from
|
|
// blocking syscalls or cgo.
|
|
{name: "/sched/threads/total:threads", typ: "gauge"},
|
|
|
|
// Time spent blocked on mutexes. cmd/derper used to export this
|
|
// itself as go_sync_mutex_wait_seconds.
|
|
{name: "/sync/mutex/wait/total:seconds", typ: "counter"},
|
|
|
|
// The /tailscale/ metrics exist only in the Tailscale fork of Go
|
|
// (github.com/tailscale/go), used when building with the
|
|
// tailscale_go build tag.
|
|
{name: "/tailscale/sched/goroutines-by-stack-size:bytes", typ: "histogram", buckets: histPowersOfTwo},
|
|
{name: "/tailscale/sched/stacks/copied:bytes", typ: "counter"},
|
|
{name: "/tailscale/sched/stacks/growths:events", typ: "counter"},
|
|
{name: "/tailscale/sched/stacks/shrinks:events", typ: "counter"},
|
|
{name: "/tailscale/sched/timers/tracked:timers", typ: "gauge"},
|
|
{name: "/tailscale/sched/timers/zombies/chan:timers", typ: "gauge"},
|
|
{name: "/tailscale/sched/timers/zombies/func:timers", typ: "gauge"},
|
|
{name: "/tailscale/sched/timers/zombies/func/created:timers", typ: "counter"},
|
|
{name: "/tailscale/sched/timers/zombies/func/removed:timers", typ: "counter"},
|
|
{name: "/tailscale/sched/timers/zombies/func/lifetime:gc-cycles", typ: "histogram", buckets: histInts},
|
|
{name: "/tailscale/sched/timers/zombies:timers", typ: "gauge"},
|
|
}
|
|
|
|
// runtimeMetricPrefix is the namespace prefix of exported runtime
|
|
// metrics. It is deliberately not "go_", which is the namespace used
|
|
// by the Prometheus Go client's collector; binaries that also use that
|
|
// client (see tailscale.com/tsweb/promvarz) would otherwise export
|
|
// some series twice, and Prometheus rejects a scrape containing
|
|
// duplicate series.
|
|
const runtimeMetricPrefix = "go_runtime_"
|
|
|
|
// runtimeMetricPromName converts a runtime/metrics name such as
|
|
// "/gc/heap/allocs:bytes" to a Prometheus metric name such as
|
|
// "go_runtime_gc_heap_allocs_bytes_total". The conversion follows the
|
|
// same rules as the Prometheus Go client's collector, other than the
|
|
// namespace: the path becomes the name with slashes and dashes
|
|
// replaced by underscores, the unit is appended, and counters get a
|
|
// "_total" suffix.
|
|
func runtimeMetricPromName(name, typ string) string {
|
|
path, unit, _ := strings.Cut(strings.TrimPrefix(name, "/"), ":")
|
|
r := strings.NewReplacer("/", "_", "-", "_")
|
|
unit = strings.ReplaceAll(unit, "/", "_per_")
|
|
unit = strings.ReplaceAll(unit, "*", "_")
|
|
s := runtimeMetricPrefix + r.Replace(path) + "_" + r.Replace(unit)
|
|
if typ == "counter" {
|
|
s += "_total"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Time histograms from the runtime have hundreds of buckets, four per
|
|
// power of two from 256ns (finer below that) to about a day. That is
|
|
// far too many series for Prometheus, so they are reduced to one
|
|
// bucket per factor of runtimeHistBucketFactor starting at
|
|
// runtimeHistMinBucket, and everything above runtimeHistMaxBucket is
|
|
// folded into the +Inf bucket. Because the runtime's bucket boundaries
|
|
// are at powers of two times small integers, a factor of 4 starting at
|
|
// 256ns lands exactly on runtime boundaries, so the reduction only
|
|
// merges runtime buckets and never splits one.
|
|
const (
|
|
runtimeHistMinBucket = 256e-9 // seconds
|
|
runtimeHistMaxBucket = 1.0 // seconds
|
|
runtimeHistBucketFactor = 4
|
|
)
|
|
|
|
// runtimeHistLayout describes how a runtime histogram's buckets are
|
|
// merged into the exported Prometheus buckets. It is computed once per
|
|
// metric from the runtime's bucket boundaries, which never change.
|
|
type runtimeHistLayout struct {
|
|
// lines[i] is the exported line prefix for output bucket i, up to
|
|
// and including the space before the value:
|
|
// `name_bucket{le="0.001"} `. The last entry is the +Inf bucket.
|
|
lines [][]byte
|
|
|
|
// ends[i] is the number of leading runtime bucket counts summed
|
|
// into output bucket i, which is cumulative like Prometheus
|
|
// buckets. The last entry, for +Inf, is the total number of
|
|
// runtime buckets.
|
|
ends []int
|
|
}
|
|
|
|
// newRuntimeHistLayout computes the export layout for a runtime
|
|
// histogram with the given bucket boundaries (as in
|
|
// [metrics.Float64Histogram.Buckets], so the runtime bucket i counts
|
|
// values in [buckets[i], buckets[i+1])).
|
|
func newRuntimeHistLayout(promName string, buckets []float64, kind histBuckets) *runtimeHistLayout {
|
|
numCounts := len(buckets) - 1
|
|
lay := new(runtimeHistLayout)
|
|
addBucket := func(le float64, end int) {
|
|
line := append([]byte(promName), `_bucket{le="`...)
|
|
if math.IsInf(le, 1) {
|
|
line = append(line, "+Inf"...)
|
|
} else {
|
|
line = strconv.AppendFloat(line, le, 'g', -1, 64)
|
|
}
|
|
line = append(line, `"} `...)
|
|
lay.lines = append(lay.lines, line)
|
|
lay.ends = append(lay.ends, end)
|
|
}
|
|
switch kind {
|
|
case histPowersOfTwo:
|
|
// All but the final catch-all runtime bucket, each labeled
|
|
// with its lower bound.
|
|
for i := 0; i < numCounts-1; i++ {
|
|
addBucket(buckets[i], i+1)
|
|
}
|
|
case histInts:
|
|
// All but the final catch-all runtime bucket, each labeled
|
|
// with the largest integer it contains.
|
|
for i := 1; i < numCounts; i++ {
|
|
addBucket(buckets[i]-1, i)
|
|
}
|
|
case histTime:
|
|
last := 0.0
|
|
for i := 1; i < numCounts; i++ {
|
|
hi := buckets[i]
|
|
if math.IsInf(hi, 0) || hi < runtimeHistMinBucket {
|
|
continue
|
|
}
|
|
if last > runtimeHistMaxBucket {
|
|
break
|
|
}
|
|
if last == 0 || hi >= last*runtimeHistBucketFactor {
|
|
addBucket(hi, i)
|
|
last = hi
|
|
}
|
|
}
|
|
}
|
|
addBucket(math.Inf(1), numCounts)
|
|
return lay
|
|
}
|
|
|
|
// runtimeMetricState is a metric being exported, alongside its
|
|
// [metrics.Sample] in [runtimeMetricsExporter.samples].
|
|
type runtimeMetricState struct {
|
|
spec runtimeMetricSpec
|
|
|
|
// typeLine is the "# TYPE name type\n" line.
|
|
typeLine []byte
|
|
|
|
// valuePrefix is "name " for scalar metrics, or nil for histograms.
|
|
valuePrefix []byte
|
|
|
|
// countPrefix is "name_count " for histograms, or nil otherwise.
|
|
countPrefix []byte
|
|
|
|
// hist is the bucket layout for histograms, or nil otherwise.
|
|
hist *runtimeHistLayout
|
|
}
|
|
|
|
// runtimeMetricsExporter reads a fixed set of Go runtime/metrics and
|
|
// writes them in Prometheus format. There is one, [runtimeMetrics],
|
|
// and it keeps its sample slice across reads so that the runtime can
|
|
// reuse histogram buffers and a steady-state scrape does not allocate.
|
|
type runtimeMetricsExporter struct {
|
|
mu sync.Mutex
|
|
inited bool
|
|
samples []metrics.Sample // reused across reads; parallel to states
|
|
states []*runtimeMetricState // parallel to samples
|
|
|
|
// Indexes into samples of the metrics backing the legacy
|
|
// memstats_* names, or -1 if the runtime lacks them.
|
|
memstatsIdx memstatsIndexes
|
|
}
|
|
|
|
// memstatsIndexes holds indexes into [runtimeMetricsExporter.samples]
|
|
// for the runtime/metrics equivalents of the runtime.MemStats fields
|
|
// that varz has exported as memstats_* since before runtime/metrics
|
|
// existed. Those names are kept for existing dashboards, but they are
|
|
// now read from runtime/metrics rather than runtime.ReadMemStats,
|
|
// which stops the world.
|
|
type memstatsIndexes struct {
|
|
heapAlloc int // /memory/classes/heap/objects:bytes
|
|
totalAlloc int // /gc/heap/allocs:bytes
|
|
sys int // /memory/classes/total:bytes
|
|
mallocs int // /gc/heap/allocs:objects
|
|
frees int // /gc/heap/frees:objects
|
|
tinyAllocs int // /gc/heap/tiny/allocs:objects
|
|
numGC int // /gc/cycles/total:gc-cycles
|
|
gcCPU int // /cpu/classes/gc/total:cpu-seconds
|
|
gcIdleCPU int // /cpu/classes/gc/mark/idle:cpu-seconds
|
|
totalCPU int // /cpu/classes/total:cpu-seconds
|
|
}
|
|
|
|
// runtimeMetrics is the process-wide exporter used by [Handler].
|
|
var runtimeMetrics = new(runtimeMetricsExporter)
|
|
|
|
// runtimeMetricsBufPool holds *[]byte output buffers so that a scrape
|
|
// can be formatted under the exporter's lock and written to the
|
|
// (possibly slow) client after releasing it.
|
|
var runtimeMetricsBufPool = sync.Pool{New: func() any { return new([]byte) }}
|
|
|
|
// init reads every metric in [runtimeMetricSpecs] once, drops the ones
|
|
// this toolchain does not know, and precomputes the per-metric output
|
|
// prefixes and histogram layouts.
|
|
//
|
|
// e.mu must be held.
|
|
func (e *runtimeMetricsExporter) init() {
|
|
e.inited = true
|
|
|
|
samples := make([]metrics.Sample, len(runtimeMetricSpecs))
|
|
for i, spec := range runtimeMetricSpecs {
|
|
samples[i].Name = spec.name
|
|
}
|
|
metrics.Read(samples)
|
|
|
|
byName := map[string]int{}
|
|
for i, spec := range runtimeMetricSpecs {
|
|
s := samples[i]
|
|
var ok bool
|
|
switch s.Value.Kind() {
|
|
case metrics.KindUint64, metrics.KindFloat64:
|
|
ok = spec.typ != "histogram"
|
|
case metrics.KindFloat64Histogram:
|
|
ok = spec.typ == "histogram"
|
|
}
|
|
if !ok {
|
|
// Unknown to this toolchain, or its kind changed
|
|
// out from under our table.
|
|
continue
|
|
}
|
|
st := &runtimeMetricState{spec: spec}
|
|
if !spec.hidden {
|
|
name := runtimeMetricPromName(spec.name, spec.typ)
|
|
st.typeLine = []byte("# TYPE " + name + " " + spec.typ + "\n")
|
|
if spec.typ == "histogram" {
|
|
st.hist = newRuntimeHistLayout(name, s.Value.Float64Histogram().Buckets, spec.buckets)
|
|
st.countPrefix = []byte(name + "_count ")
|
|
} else {
|
|
st.valuePrefix = []byte(name + " ")
|
|
}
|
|
}
|
|
byName[spec.name] = len(e.samples)
|
|
e.samples = append(e.samples, s)
|
|
e.states = append(e.states, st)
|
|
}
|
|
|
|
idx := func(name string) int {
|
|
if i, ok := byName[name]; ok {
|
|
return i
|
|
}
|
|
return -1
|
|
}
|
|
e.memstatsIdx = memstatsIndexes{
|
|
heapAlloc: idx("/memory/classes/heap/objects:bytes"),
|
|
totalAlloc: idx("/gc/heap/allocs:bytes"),
|
|
sys: idx("/memory/classes/total:bytes"),
|
|
mallocs: idx("/gc/heap/allocs:objects"),
|
|
frees: idx("/gc/heap/frees:objects"),
|
|
tinyAllocs: idx("/gc/heap/tiny/allocs:objects"),
|
|
numGC: idx("/gc/cycles/total:gc-cycles"),
|
|
gcCPU: idx("/cpu/classes/gc/total:cpu-seconds"),
|
|
gcIdleCPU: idx("/cpu/classes/gc/mark/idle:cpu-seconds"),
|
|
totalCPU: idx("/cpu/classes/total:cpu-seconds"),
|
|
}
|
|
}
|
|
|
|
// writeTo reads the current values of the exported metrics and writes
|
|
// them to w in Prometheus text format, preceded by the legacy
|
|
// memstats_* metrics.
|
|
func (e *runtimeMetricsExporter) writeTo(w io.Writer) error {
|
|
bp := runtimeMetricsBufPool.Get().(*[]byte)
|
|
defer runtimeMetricsBufPool.Put(bp)
|
|
|
|
e.mu.Lock()
|
|
if !e.inited {
|
|
e.init()
|
|
} else {
|
|
metrics.Read(e.samples)
|
|
}
|
|
*bp = e.appendMemstats((*bp)[:0])
|
|
for i, st := range e.states {
|
|
if !st.spec.hidden {
|
|
*bp = st.appendTo(*bp, &e.samples[i].Value)
|
|
}
|
|
}
|
|
e.mu.Unlock()
|
|
|
|
_, err := w.Write(*bp)
|
|
return err
|
|
}
|
|
|
|
// appendTo appends the Prometheus lines for st with value v to b.
|
|
func (st *runtimeMetricState) appendTo(b []byte, v *metrics.Value) []byte {
|
|
b = append(b, st.typeLine...)
|
|
switch v.Kind() {
|
|
case metrics.KindUint64:
|
|
b = append(b, st.valuePrefix...)
|
|
b = strconv.AppendUint(b, v.Uint64(), 10)
|
|
b = append(b, '\n')
|
|
case metrics.KindFloat64:
|
|
b = append(b, st.valuePrefix...)
|
|
b = strconv.AppendFloat(b, v.Float64(), 'g', -1, 64)
|
|
b = append(b, '\n')
|
|
case metrics.KindFloat64Histogram:
|
|
counts := v.Float64Histogram().Counts
|
|
var sum uint64
|
|
var next int
|
|
for i, end := range st.hist.ends {
|
|
for ; next < end && next < len(counts); next++ {
|
|
sum += counts[next]
|
|
}
|
|
b = append(b, st.hist.lines[i]...)
|
|
b = strconv.AppendUint(b, sum, 10)
|
|
b = append(b, '\n')
|
|
}
|
|
b = append(b, st.countPrefix...)
|
|
b = strconv.AppendUint(b, sum, 10)
|
|
b = append(b, '\n')
|
|
}
|
|
return b
|
|
}
|
|
|
|
// appendMemstats appends the legacy memstats_* metrics to b, sourced
|
|
// from the runtime/metrics samples named by e.memstatsIdx.
|
|
//
|
|
// e.mu must be held and e.samples must have been read.
|
|
func (e *runtimeMetricsExporter) appendMemstats(b []byte) []byte {
|
|
idx := &e.memstatsIdx
|
|
u := func(i int) (uint64, bool) {
|
|
if i < 0 {
|
|
return 0, false
|
|
}
|
|
v := &e.samples[i].Value
|
|
if v.Kind() != metrics.KindUint64 {
|
|
return 0, false
|
|
}
|
|
return v.Uint64(), true
|
|
}
|
|
f := func(i int) (float64, bool) {
|
|
if i < 0 {
|
|
return 0, false
|
|
}
|
|
v := &e.samples[i].Value
|
|
if v.Kind() != metrics.KindFloat64 {
|
|
return 0, false
|
|
}
|
|
return v.Float64(), true
|
|
}
|
|
appendUint := func(typ, name string, v uint64, ok bool, help string) {
|
|
if ok {
|
|
b = appendMemstat(b, typ, name, help)
|
|
b = strconv.AppendUint(b, v, 10)
|
|
b = append(b, '\n')
|
|
}
|
|
}
|
|
appendIdx := func(typ, name string, i int, help string) {
|
|
v, ok := u(i)
|
|
appendUint(typ, name, v, ok, help)
|
|
}
|
|
appendIdx("gauge", "heap_alloc", idx.heapAlloc, "current bytes of allocated heap objects (up/down smoothly)")
|
|
appendIdx("counter", "total_alloc", idx.totalAlloc, "cumulative bytes allocated for heap objects")
|
|
appendIdx("gauge", "sys", idx.sys, "total bytes of memory obtained from the OS")
|
|
|
|
// runtime.ReadMemStats counts tiny allocations in both Mallocs
|
|
// and Frees, but /gc/heap/allocs:objects and /gc/heap/frees:objects
|
|
// exclude them, so add them back.
|
|
mallocs, ok1 := u(idx.mallocs)
|
|
frees, ok2 := u(idx.frees)
|
|
tiny, ok3 := u(idx.tinyAllocs)
|
|
appendUint("counter", "mallocs", mallocs+tiny, ok1 && ok3, "cumulative count of heap objects allocated")
|
|
appendUint("counter", "frees", frees+tiny, ok2 && ok3, "cumulative count of heap objects freed")
|
|
|
|
appendIdx("counter", "num_gc", idx.numGC, "number of completed GC cycles")
|
|
gc, ok1 := f(idx.gcCPU)
|
|
idle, ok2 := f(idx.gcIdleCPU)
|
|
total, ok3 := f(idx.totalCPU)
|
|
if ok1 && ok2 && ok3 {
|
|
// This is how the runtime computes MemStats.GCCPUFraction:
|
|
// GC time less idle-priority marking, which is "free", over
|
|
// total time. The runtime updates its CPU class stats at
|
|
// the end of each GC cycle, so total is 0 until the first
|
|
// one, as GCCPUFraction was too.
|
|
var frac float64
|
|
if total > 0 {
|
|
frac = (gc - idle) / total
|
|
}
|
|
b = appendMemstat(b, "gauge", "gc_cpu_fraction", "fraction of CPU time used by GC")
|
|
b = strconv.AppendFloat(b, frac, 'f', -1, 64)
|
|
b = append(b, '\n')
|
|
}
|
|
return b
|
|
}
|
|
|
|
// appendMemstat appends the HELP and TYPE lines for the memstats_*
|
|
// metric name to b, followed by the metric name and a space, leaving
|
|
// the caller to append the value and newline.
|
|
func appendMemstat(b []byte, typ, name, help string) []byte {
|
|
b = append(b, "# HELP memstats_"...)
|
|
b = append(b, name...)
|
|
b = append(b, ' ')
|
|
b = append(b, help...)
|
|
b = append(b, "\n# TYPE memstats_"...)
|
|
b = append(b, name...)
|
|
b = append(b, ' ')
|
|
b = append(b, typ...)
|
|
b = append(b, "\nmemstats_"...)
|
|
b = append(b, name...)
|
|
b = append(b, ' ')
|
|
return b
|
|
}
|