Checkpoint
This commit is contained in:
213
stats/handler.go
Normal file
213
stats/handler.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"html/template"
|
||||
)
|
||||
|
||||
var (
|
||||
page = template.Must(template.New("").
|
||||
Funcs(template.FuncMap{"path": path, "duration": duration}).
|
||||
Parse(`<!DOCTYPE html>
|
||||
<html lang="us">
|
||||
<meta charset="utf-8">
|
||||
<title>Metrics report</title>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: monospace; font-size: 12px; }
|
||||
.container {
|
||||
max-width: 640px;
|
||||
margin: 1em auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 1em;
|
||||
}
|
||||
h1 { text-align: center; }
|
||||
h2 {
|
||||
font-weight: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric {
|
||||
padding: 1em 0;
|
||||
border-top: 1px solid rgba(0,0,0,0.33);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
.col-1 { flex: 1; }
|
||||
.col-2 { flex: 2.5; }
|
||||
.table { width: 100px; border-radius: 2px; border: 1px solid rgba(0,0,0,0.33); }
|
||||
.table td, .table th { text-align: center; }
|
||||
.timeline { padding: 0 0.5em; }
|
||||
path { fill: none; stroke: rgba(0,0,0,0.33); stroke-width: 1; stroke-linecap: round; stroke-linejoin: round; }
|
||||
path:last-child { stroke: black; }
|
||||
</style>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div><h1><pre> __ __
|
||||
.--------..-----.| |_ .----.|__|.----..-----.
|
||||
| || -__|| _|| _|| || __||__ --|
|
||||
|__|__|__||_____||____||__| |__||____||_____|
|
||||
|
||||
|
||||
</pre></h1></div>
|
||||
{{ range . }}
|
||||
<div class="row metric">
|
||||
<h2 class="col-1">{{ .name }}</h2>
|
||||
<div class="col-2">
|
||||
{{ if .type }}
|
||||
<div class="row">
|
||||
{{ template "table" . }}
|
||||
<div class="col-1"></div>
|
||||
</div>
|
||||
{{ else if .interval }}
|
||||
<div class="row">{{ template "timeseries" . }}</div>
|
||||
{{ else if .metrics}}
|
||||
{{ range .metrics }}
|
||||
<div class="row">
|
||||
{{ template "timeseries" . }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{ define "table" }}
|
||||
<table class="table col-1">
|
||||
{{ if eq .type "c" }}
|
||||
<thead><tr><th>count</th></tr></thead><tbody><tr><td>{{ printf "%.2g" .count }}</td></tr></tbody>
|
||||
{{ else if eq .type "g" }}
|
||||
<thead><tr><th>mean</th><th>min</th><th>max</th></tr></thead>
|
||||
<tbody><tr><td>{{printf "%.2g" .mean}}</td><td>{{printf "%.2g" .min}}</td><td>{{printf "%.2g" .max}}</td></th></tbody>
|
||||
{{ else if eq .type "h" }}
|
||||
<thead><tr><th>P.50</th><th>P.90</th><th>P.99</th></tr></thead>
|
||||
<tbody><tr><td>{{printf "%.2g" .p50}}</td><td>{{printf "%.2g" .p90}}</td><td>{{printf "%.2g" .p99}}</td></tr></tbody>
|
||||
{{ end }}
|
||||
</table>
|
||||
{{ end }}
|
||||
{{ define "timeseries" }}
|
||||
{{ template "table" .total }}
|
||||
<div class="col-1">
|
||||
<div class="row">
|
||||
<div class="timeline">{{ duration .samples .interval }}</div>
|
||||
<svg class="col-1" version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20">
|
||||
{{ if eq (index (index .samples 0) "type") "c" }}
|
||||
{{ range (path .samples "count") }}<path d={{ . }} />{{end}}
|
||||
{{ else if eq (index (index .samples 0) "type") "g" }}
|
||||
{{ range (path .samples "min" "max" "mean" ) }}<path d={{ . }} />{{end}}
|
||||
{{ else if eq (index (index .samples 0) "type") "h" }}
|
||||
{{ range (path .samples "p50" "p90" "p99") }}<path d={{ . }} />{{end}}
|
||||
{{ end }}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
`))
|
||||
)
|
||||
|
||||
func path(samples []any, keys ...string) []string {
|
||||
var min, max float64
|
||||
paths := make([]string, len(keys))
|
||||
for i := range len(samples) {
|
||||
s := samples[i].(map[string]any)
|
||||
for _, k := range keys {
|
||||
x := s[k].(float64)
|
||||
if i == 0 || x < min {
|
||||
min = x
|
||||
}
|
||||
if i == 0 || x > max {
|
||||
max = x
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range len(samples) {
|
||||
s := samples[i].(map[string]any)
|
||||
for j, k := range keys {
|
||||
v := s[k].(float64)
|
||||
x := float64(i+1) / float64(len(samples))
|
||||
y := (v - min) / (max - min)
|
||||
if max == min {
|
||||
y = 0
|
||||
}
|
||||
if i == 0 {
|
||||
paths[j] = fmt.Sprintf("M%f %f", 0.0, (1-y)*18+1)
|
||||
}
|
||||
paths[j] += fmt.Sprintf(" L%f %f", x*100, (1-y)*18+1)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func duration(samples []any, n float64) string {
|
||||
n = n * float64(len(samples))
|
||||
if n < 60 {
|
||||
return fmt.Sprintf("%d sec", int(n))
|
||||
} else if n < 60*60 {
|
||||
return fmt.Sprintf("%d min", int(n/60))
|
||||
} else if n < 24*60*60 {
|
||||
return fmt.Sprintf("%d hrs", int(n/60/60))
|
||||
}
|
||||
return fmt.Sprintf("%d days", int(n/24/60/60))
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that renders web UI for all provided metrics.
|
||||
func Handler(snapshot func() map[string]Metric) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type h map[string]any
|
||||
metrics := []h{}
|
||||
for name, metric := range snapshot() {
|
||||
m := h{}
|
||||
b, _ := json.Marshal(metric)
|
||||
json.Unmarshal(b, &m)
|
||||
m["name"] = name
|
||||
metrics = append(metrics, m)
|
||||
}
|
||||
sort.Slice(metrics, func(i, j int) bool {
|
||||
n1 := metrics[i]["name"].(string)
|
||||
n2 := metrics[j]["name"].(string)
|
||||
return strings.Compare(n1, n2) < 0
|
||||
})
|
||||
page.Execute(w, metrics)
|
||||
})
|
||||
}
|
||||
|
||||
// JSONHandler returns a [http.Handler] that renders the metrics as JSON.
|
||||
func JSONHandler(snapshot func() map[string]Metric) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
type h map[string]any
|
||||
metrics := map[string]h{}
|
||||
for name, metric := range snapshot() {
|
||||
m := h{}
|
||||
b, _ := json.Marshal(metric)
|
||||
json.Unmarshal(b, &m)
|
||||
metrics[name] = m
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(metrics)
|
||||
})
|
||||
}
|
||||
|
||||
// Exposed returns a map of exposed metrics (see expvar package).
|
||||
func Exposed() map[string]Metric {
|
||||
m := map[string]Metric{}
|
||||
expvar.Do(func(kv expvar.KeyValue) {
|
||||
if metric, ok := kv.Value.(Metric); ok {
|
||||
m[kv.Key] = metric
|
||||
}
|
||||
})
|
||||
return m
|
||||
}
|
104
stats/stats.go
Normal file
104
stats/stats.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Metric is a single meter (counter, gauge or histogram, optionally - with history)
|
||||
type Metric interface {
|
||||
Add(n float64)
|
||||
String() string
|
||||
}
|
||||
|
||||
// metric is an extended private interface with some additional internal
|
||||
// methods used by timeseries. Counters, gauges and histograms implement it.
|
||||
type metric interface {
|
||||
Metric
|
||||
Reset()
|
||||
Aggregate(roll int, samples []metric)
|
||||
}
|
||||
|
||||
type multimetric []*timeseries
|
||||
|
||||
func (mm multimetric) Add(n float64) {
|
||||
for _, m := range mm {
|
||||
m.Add(n)
|
||||
}
|
||||
}
|
||||
|
||||
func (mm multimetric) MarshalJSON() ([]byte, error) {
|
||||
b := []byte(`{"metrics":[`)
|
||||
for i, m := range mm {
|
||||
if i != 0 {
|
||||
b = append(b, ',')
|
||||
}
|
||||
x, _ := json.Marshal(m)
|
||||
b = append(b, x...)
|
||||
}
|
||||
b = append(b, ']', '}')
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (mm multimetric) String() string {
|
||||
return mm[len(mm)-1].String()
|
||||
}
|
||||
|
||||
func newMetric(builder func() metric, frames ...string) Metric {
|
||||
if len(frames) == 0 {
|
||||
return builder()
|
||||
}
|
||||
if len(frames) == 1 {
|
||||
return newTimeseries(builder, frames[0])
|
||||
}
|
||||
mm := multimetric{}
|
||||
for _, frame := range frames {
|
||||
mm = append(mm, newTimeseries(builder, frame))
|
||||
}
|
||||
sort.Slice(mm, func(i, j int) bool {
|
||||
a, b := mm[i], mm[j]
|
||||
return a.interval.Seconds()*float64(len(a.samples)) < b.interval.Seconds()*float64(len(b.samples))
|
||||
})
|
||||
return mm
|
||||
}
|
||||
|
||||
// NewCounter returns a counter metric that increments the value with each
|
||||
// incoming number.
|
||||
func NewCounter(frames ...string) Metric {
|
||||
return newMetric(func() metric { return &counter{} }, frames...)
|
||||
}
|
||||
|
||||
type counter struct {
|
||||
count uint64
|
||||
}
|
||||
|
||||
func (c *counter) String() string { return strconv.FormatFloat(c.value(), 'g', -1, 64) }
|
||||
func (c *counter) Reset() { atomic.StoreUint64(&c.count, math.Float64bits(0)) }
|
||||
func (c *counter) value() float64 { return math.Float64frombits(atomic.LoadUint64(&c.count)) }
|
||||
|
||||
func (c *counter) Add(n float64) {
|
||||
for {
|
||||
old := math.Float64frombits(atomic.LoadUint64(&c.count))
|
||||
new := old + n
|
||||
if atomic.CompareAndSwapUint64(&c.count, math.Float64bits(old), math.Float64bits(new)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *counter) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
Count float64 `json:"count"`
|
||||
}{"c", c.value()})
|
||||
}
|
||||
|
||||
func (c *counter) Aggregate(roll int, samples []metric) {
|
||||
c.Reset()
|
||||
for _, s := range samples {
|
||||
c.Add(s.(*counter).value())
|
||||
}
|
||||
}
|
105
stats/timeseries.go
Normal file
105
stats/timeseries.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type timeseries struct {
|
||||
sync.Mutex
|
||||
now time.Time
|
||||
size int
|
||||
interval time.Duration
|
||||
total metric
|
||||
samples []metric
|
||||
}
|
||||
|
||||
func (ts *timeseries) Reset() {
|
||||
ts.total.Reset()
|
||||
for _, s := range ts.samples {
|
||||
s.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *timeseries) roll() {
|
||||
t := time.Now()
|
||||
roll := int((t.Round(ts.interval).Sub(ts.now.Round(ts.interval))) / ts.interval)
|
||||
ts.now = t
|
||||
n := len(ts.samples)
|
||||
if roll <= 0 {
|
||||
return
|
||||
}
|
||||
if roll >= len(ts.samples) {
|
||||
ts.Reset()
|
||||
} else {
|
||||
for i := 0; i < roll; i++ {
|
||||
tmp := ts.samples[n-1]
|
||||
for j := n - 1; j > 0; j-- {
|
||||
ts.samples[j] = ts.samples[j-1]
|
||||
}
|
||||
ts.samples[0] = tmp
|
||||
ts.samples[0].Reset()
|
||||
}
|
||||
ts.total.Aggregate(roll, ts.samples)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *timeseries) Add(n float64) {
|
||||
ts.Lock()
|
||||
defer ts.Unlock()
|
||||
ts.roll()
|
||||
ts.total.Add(n)
|
||||
ts.samples[0].Add(n)
|
||||
}
|
||||
|
||||
func (ts *timeseries) MarshalJSON() ([]byte, error) {
|
||||
ts.Lock()
|
||||
defer ts.Unlock()
|
||||
ts.roll()
|
||||
return json.Marshal(struct {
|
||||
Interval float64 `json:"interval"`
|
||||
Total Metric `json:"total"`
|
||||
Samples []metric `json:"samples"`
|
||||
}{float64(ts.interval) / float64(time.Second), ts.total, ts.samples})
|
||||
}
|
||||
|
||||
func (ts *timeseries) String() string {
|
||||
ts.Lock()
|
||||
defer ts.Unlock()
|
||||
ts.roll()
|
||||
return ts.total.String()
|
||||
}
|
||||
|
||||
func newTimeseries(builder func() metric, frame string) *timeseries {
|
||||
var (
|
||||
totalNum, intervalNum int
|
||||
totalUnit, intervalUnit rune
|
||||
)
|
||||
units := map[rune]time.Duration{
|
||||
's': time.Second,
|
||||
'm': time.Minute,
|
||||
'h': time.Hour,
|
||||
'd': time.Hour * 24,
|
||||
'w': time.Hour * 24 * 7,
|
||||
'M': time.Hour * 24 * 30,
|
||||
'y': time.Hour * 24 * 365,
|
||||
}
|
||||
fmt.Sscanf(frame, "%d%c%d%c", &totalNum, &totalUnit, &intervalNum, &intervalUnit)
|
||||
interval := units[intervalUnit] * time.Duration(intervalNum)
|
||||
if interval == 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
totalDuration := units[totalUnit] * time.Duration(totalNum)
|
||||
if totalDuration == 0 {
|
||||
totalDuration = interval * 15
|
||||
}
|
||||
n := int(totalDuration / interval)
|
||||
samples := make([]metric, n, n)
|
||||
for i := 0; i < n; i++ {
|
||||
samples[i] = builder()
|
||||
}
|
||||
totalMetric := builder()
|
||||
return ×eries{interval: interval, total: totalMetric, samples: samples}
|
||||
}
|
Reference in New Issue
Block a user