package metrics
import (
"math"
"sort"
"strconv"
"strings"
"sync"
)
// DurationBucketsSec 匹配 Prometheus client_golang 的默认延迟桶布局。
// 适合 HTTP/RPC 调用、handler 分发、Profile 读写、Redis/MySQL 命令、Lua 限流等
// 大部分游戏服务端"亚秒级到秒级"的请求延迟。需要更长尾时显式传 Buckets 覆盖。
var DurationBucketsSec = []float64{
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
}
// Histogram 是观察值(默认按秒)的分布统计;按 Prometheus 协议输出为
// _bucket / _count / _sum 三类样本族。
//
// Histogram 自身只维护内存累加状态;最终样本通过注册的 Observer 在每次
// scrape 触发时写回 Registry。这使得 Histogram 可以承载高频 Observe 而不
// 每次都 touch Registry 的全局 map。
//
// 调用方应在启动时构造一次 Histogram 并复用,不要在每次请求里 New。
type Histogram struct {
registry *Registry
name string
buckets []float64
base map[string]string
mu sync.Mutex
series map[string]*histogramSeries
}
type histogramSeries struct {
labels map[string]string
bucketCnt []uint64
count uint64
sumSec float64
}
// HistogramOptions 用于构造 Histogram。空字段会回退到合理默认。
type HistogramOptions struct {
// Buckets 是上界(秒)切片,必须严格递增且为正;空切片回退到 DurationBucketsSec。
// 不需要手动加 +Inf 桶,Histogram 在序列化时自动追加。
Buckets []float64
// Labels 是该 Histogram 上所有样本共享的基础标签,常见用法是固定 service、node。
Labels map[string]string
}
// NewHistogram 构造一个 Histogram 并注册成该 Scope 对应 Registry 的 observer。
// observerKey 与 metric name 关联,重复构造同名 Histogram 会替换前一个 observer。
func NewHistogram(scope Scope, name string, opts HistogramOptions) *Histogram {
if scope.registry == nil {
return &Histogram{name: scope.Name(name)}
}
fullName := scope.Name(name)
if fullName == "" {
return &Histogram{}
}
buckets := normalizeBuckets(opts.Buckets)
h := &Histogram{
registry: scope.registry,
name: fullName,
buckets: buckets,
base: scope.Labels(opts.Labels),
series: make(map[string]*histogramSeries),
}
scope.registry.RegisterObserver("histogram:"+fullName, func(r *Registry) {
h.flush(r)
})
return h
}
// Buckets 返回该 Histogram 当前生效的上界(不含 +Inf)。仅用于调试 / 测试。
func (h *Histogram) Buckets() []float64 {
if h == nil {
return nil
}
out := make([]float64, len(h.buckets))
copy(out, h.buckets)
return out
}
// Observe 记录一次观察值,默认无附加 labels。
func (h *Histogram) Observe(seconds float64) {
h.ObserveWithLabels(seconds, nil)
}
// ObserveWithLabels 记录一次观察值并附加 per-observation labels。labels 会与
// HistogramOptions.Labels 合并;每组不同的 labels 都会成为独立的样本序列。
func (h *Histogram) ObserveWithLabels(seconds float64, labels map[string]string) {
if h == nil || h.registry == nil {
return
}
if math.IsNaN(seconds) || math.IsInf(seconds, 0) {
return
}
if seconds < 0 {
seconds = 0
}
merged := mergeLabels(h.base, labels)
key := labelsKey(merged)
h.mu.Lock()
series, ok := h.series[key]
if !ok {
series = &histogramSeries{
labels: cloneLabels(merged),
bucketCnt: make([]uint64, len(h.buckets)),
}
h.series[key] = series
}
series.count++
series.sumSec += seconds
for i, upper := range h.buckets {
if seconds <= upper {
series.bucketCnt[i]++
}
}
h.mu.Unlock()
}
// flush 把内存累加状态写回 Registry。在每次 scrape 由 RunObservers 触发。
// 我们以"绝对值"写回(SetHistogramSample 替换语义),避免与 normalizeSamples
// 的累加路径互相干扰。
func (h *Histogram) flush(registry *Registry) {
if h == nil || registry == nil {
return
}
h.mu.Lock()
snapshot := make([]*histogramSeries, 0, len(h.series))
for _, series := range h.series {
snapshot = append(snapshot, &histogramSeries{
labels: cloneLabels(series.labels),
bucketCnt: append([]uint64(nil), series.bucketCnt...),
count: series.count,
sumSec: series.sumSec,
})
}
h.mu.Unlock()
for _, series := range snapshot {
for i, upper := range h.buckets {
leLabels := withLabel(series.labels, "le", formatBucketBound(upper))
registry.SetHistogramSample(h.name+"_bucket", leLabels, Int64FromUint64(series.bucketCnt[i]), false, 0)
}
// +Inf 桶等价于 count,但 Prometheus 协议要求它显式出现。
infLabels := withLabel(series.labels, "le", "+Inf")
registry.SetHistogramSample(h.name+"_bucket", infLabels, Int64FromUint64(series.count), false, 0)
registry.SetHistogramSample(h.name+"_count", series.labels, Int64FromUint64(series.count), false, 0)
registry.SetHistogramSample(h.name+"_sum", series.labels, 0, true, series.sumSec)
}
}
// normalizeBuckets 校验 / 复制 / 排序 buckets。空输入返回默认;输入会被复制
// 以避免调用方后续修改影响 Histogram 内部状态。
func normalizeBuckets(in []float64) []float64 {
if len(in) == 0 {
out := make([]float64, len(DurationBucketsSec))
copy(out, DurationBucketsSec)
return out
}
cleaned := make([]float64, 0, len(in))
for _, b := range in {
if math.IsNaN(b) || math.IsInf(b, 0) || b <= 0 {
continue
}
cleaned = append(cleaned, b)
}
if len(cleaned) == 0 {
return normalizeBuckets(nil)
}
sort.Float64s(cleaned)
// 去重,避免 le 标签冲突。
deduped := cleaned[:0]
for i, b := range cleaned {
if i == 0 || b != cleaned[i-1] {
deduped = append(deduped, b)
}
}
out := make([]float64, len(deduped))
copy(out, deduped)
return out
}
func withLabel(labels map[string]string, key, value string) map[string]string {
out := make(map[string]string, len(labels)+1)
for k, v := range labels {
out[k] = v
}
out[key] = value
return out
}
func formatBucketBound(value float64) string {
// Prometheus 习惯把 0.005 显示成 "0.005" 而非 "5e-03"。用 %g 在大多数桶
// 上会给出干净小数;遇到极小或极大值时再退到科学计数。
s := strconv.FormatFloat(value, 'g', -1, 64)
// 进一步避免 "1" 这种没有小数点的形式造成与整数 bucket 混淆。
if !strings.ContainsAny(s, ".eE") {
s += ".0"
}
return s
}