DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,150 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 这些名称是框架层的最低共同语言,避免每个项目自造一套不可聚合的运营指标。
|
||||
const (
|
||||
REDRequestsMetric = "requests_total"
|
||||
REDErrorsMetric = "errors_total"
|
||||
REDDurationMetric = "request_duration"
|
||||
|
||||
OnlinePlayersMetric = "online_players"
|
||||
LoginEventsMetric = "login_events_total"
|
||||
PaymentEventsMetric = "payment_events_total"
|
||||
PaymentAmountMicrosMetric = "payment_amount_micros_total"
|
||||
RetentionEventsMetric = "retention_events_total"
|
||||
)
|
||||
|
||||
// RED 把 rate、errors、duration 绑定到同一套 label 约束,避免 SLO 口径漂移。
|
||||
type RED struct {
|
||||
scope Scope
|
||||
service string
|
||||
duration *Histogram
|
||||
}
|
||||
|
||||
type REDOptions struct {
|
||||
Buckets []float64
|
||||
}
|
||||
|
||||
func NewRED(scope Scope, service string, opts REDOptions) *RED {
|
||||
service = strings.TrimSpace(service)
|
||||
if service == "" {
|
||||
service = "default"
|
||||
}
|
||||
baseLabels := map[string]string{"service": service}
|
||||
histogram := NewHistogram(scope, REDDurationMetric, HistogramOptions{
|
||||
Buckets: opts.Buckets,
|
||||
Labels: baseLabels,
|
||||
})
|
||||
return &RED{
|
||||
scope: scope.WithLabels(baseLabels),
|
||||
service: service,
|
||||
duration: histogram,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RED) Record(route string, code string, elapsed time.Duration) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
route = sanitizeLabelValue(route, "unknown")
|
||||
code = sanitizeLabelValue(code, "OK")
|
||||
labels := map[string]string{"route": route, "code": code}
|
||||
r.scope.Inc(REDRequestsMetric, labels)
|
||||
if isErrorCode(code) {
|
||||
r.scope.Inc(REDErrorsMetric, labels)
|
||||
}
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
if r.duration != nil {
|
||||
r.duration.ObserveWithLabels(elapsed.Seconds(), labels)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RED) RecordError(route string, code string, elapsed time.Duration) {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
code = "INTERNAL_ERR"
|
||||
}
|
||||
r.Record(route, code, elapsed)
|
||||
}
|
||||
|
||||
func (r *RED) Service() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.service
|
||||
}
|
||||
|
||||
func isErrorCode(code string) bool {
|
||||
upper := strings.ToUpper(strings.TrimSpace(code))
|
||||
if upper == "" || upper == "OK" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(upper, "ERR") {
|
||||
return true
|
||||
}
|
||||
switch upper {
|
||||
case "FAIL", "FAILED", "INTERNAL_ERR", "TIMEOUT", "PANIC":
|
||||
return true
|
||||
}
|
||||
return len(upper) == 3 && upper[0] == '5'
|
||||
}
|
||||
|
||||
// OnlinePlayers 固定 segment、region、server_id 三个维度,避免在线人数指标被 user_id 拉爆。
|
||||
type OnlinePlayers struct {
|
||||
scope Scope
|
||||
}
|
||||
|
||||
func NewOnlinePlayers(scope Scope, service string) *OnlinePlayers {
|
||||
service = strings.TrimSpace(service)
|
||||
if service == "" {
|
||||
service = "default"
|
||||
}
|
||||
return &OnlinePlayers{
|
||||
scope: scope.WithLabels(map[string]string{"service": service}),
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OnlinePlayers) Set(segment, region, serverID string, count int64) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
labels := map[string]string{
|
||||
"segment": sanitizeLabelValue(segment, "all"),
|
||||
"region": sanitizeLabelValue(region, "any"),
|
||||
"server_id": sanitizeLabelValue(serverID, "any"),
|
||||
}
|
||||
o.scope.SetGauge(OnlinePlayersMetric, labels, count)
|
||||
}
|
||||
|
||||
func (o *OnlinePlayers) Add(segment, region, serverID string, delta int64) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
labels := map[string]string{
|
||||
"segment": sanitizeLabelValue(segment, "all"),
|
||||
"region": sanitizeLabelValue(region, "any"),
|
||||
"server_id": sanitizeLabelValue(serverID, "any"),
|
||||
}
|
||||
o.scope.AddGauge(OnlinePlayersMetric, labels, delta)
|
||||
}
|
||||
|
||||
func sanitizeLabelValue(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
if len(value) > 64 {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < 0x20 {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package metrics
|
||||
|
||||
import "math"
|
||||
|
||||
// Int64FromUint64 将 uint64 饱和转换为 int64,避免监控计数器溢出后回绕成负数。
|
||||
func Int64FromUint64(value uint64) int64 {
|
||||
if value > math.MaxInt64 {
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(value) //nolint:gosec // G115:前面已按 int64 最大值钳制。
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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 协议输出为
|
||||
// <base>_bucket / <base>_count / <base>_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
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
TypeCounter Type = "counter"
|
||||
TypeGauge Type = "gauge"
|
||||
TypeHistogram Type = "histogram"
|
||||
)
|
||||
|
||||
// Sample 是 registry / observer 输出的最小单元。Value 用 int64 适合 counter / gauge
|
||||
// 以及 histogram 的 bucket / count;当样本表示浮点值(如 histogram 的 sum)时,
|
||||
// 设置 Float=true 并把数值写到 FloatValue。这种双字段写法兼容已有所有 counter/gauge
|
||||
// 调用方,新增字段对存量 Sample 字面量保持零值,不影响 reflect.DeepEqual 比较。
|
||||
type Sample struct {
|
||||
Name string `json:"name"`
|
||||
Type Type `json:"type"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Value int64 `json:"value"`
|
||||
Float bool `json:"float,omitempty"`
|
||||
FloatValue float64 `json:"float_value,omitempty"`
|
||||
}
|
||||
|
||||
type Observer func(*Registry)
|
||||
|
||||
type observerEntry struct {
|
||||
name string
|
||||
fn Observer
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
samples map[string]Sample
|
||||
observers []observerEntry
|
||||
}
|
||||
|
||||
func New() *Registry {
|
||||
return &Registry{samples: make(map[string]Sample)}
|
||||
}
|
||||
|
||||
// RegisterObserver 注册 scrape 时执行的回调。
|
||||
// Snapshot 向 Prometheus 客户端返回样本前会调用 observer;observer 应保持低成本
|
||||
// (例如 O(1) Redis SCARD 或本地 atomics),并且必须能承受并发调用。
|
||||
// 使用同名重新注册会替换旧回调;fn 为 nil 表示注销。
|
||||
func (r *Registry) RegisterObserver(name string, fn Observer) {
|
||||
if r == nil || strings.TrimSpace(name) == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for idx := range r.observers {
|
||||
if r.observers[idx].name == name {
|
||||
if fn == nil {
|
||||
r.observers = append(r.observers[:idx], r.observers[idx+1:]...)
|
||||
return
|
||||
}
|
||||
r.observers[idx].fn = fn
|
||||
return
|
||||
}
|
||||
}
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
r.observers = append(r.observers, observerEntry{name: name, fn: fn})
|
||||
}
|
||||
|
||||
// RunObservers 调用所有已注册 observer。
|
||||
// Prometheus handler 和 /debug/metrics 会在序列化前调用它,让观测样本反映当前状态。
|
||||
// observer 会顺序执行,不能长时间阻塞。
|
||||
func (r *Registry) RunObservers() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
entries := make([]observerEntry, len(r.observers))
|
||||
copy(entries, r.observers)
|
||||
r.mu.RUnlock()
|
||||
for _, entry := range entries {
|
||||
if entry.fn == nil {
|
||||
continue
|
||||
}
|
||||
entry.fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Inc(name string, labels map[string]string) {
|
||||
r.Add(name, labels, 1)
|
||||
}
|
||||
|
||||
func (r *Registry) Add(name string, labels map[string]string, delta int64) {
|
||||
if r == nil || name == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ensureSamplesLocked()
|
||||
|
||||
key := sampleKey(TypeCounter, name, labels)
|
||||
sample := r.samples[key]
|
||||
if sample.Name == "" {
|
||||
sample = Sample{Name: name, Type: TypeCounter, Labels: cloneLabels(labels)}
|
||||
}
|
||||
sample.Value += delta
|
||||
r.samples[key] = sample
|
||||
}
|
||||
|
||||
func (r *Registry) SetCounter(name string, labels map[string]string, value int64) {
|
||||
if r == nil || name == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ensureSamplesLocked()
|
||||
|
||||
key := sampleKey(TypeCounter, name, labels)
|
||||
r.samples[key] = Sample{Name: name, Type: TypeCounter, Labels: cloneLabels(labels), Value: value}
|
||||
}
|
||||
|
||||
func (r *Registry) SetGauge(name string, labels map[string]string, value int64) {
|
||||
if r == nil || name == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ensureSamplesLocked()
|
||||
|
||||
key := sampleKey(TypeGauge, name, labels)
|
||||
r.samples[key] = Sample{Name: name, Type: TypeGauge, Labels: cloneLabels(labels), Value: value}
|
||||
}
|
||||
|
||||
// SetHistogramSample 写入或替换 histogram family 中的一项样本。
|
||||
// Histogram family 会拆成多条共享 base 名的样本:
|
||||
// - <base>_bucket{le="..."} 累计计数,int
|
||||
// - <base>_count 总观察次数,int
|
||||
// - <base>_sum 观察值总和(float 秒,set float=true)
|
||||
//
|
||||
// observer 会在 RunObservers 中调用它发布 histogram 的当前绝对状态;
|
||||
// 和 SetGauge 一样,这里是替换语义,不做累加。Histogram.Observe 是公开 API,
|
||||
// 调用方通常不直接使用 SetHistogramSample。
|
||||
func (r *Registry) SetHistogramSample(name string, labels map[string]string, value int64, float bool, floatValue float64) {
|
||||
if r == nil || name == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ensureSamplesLocked()
|
||||
key := sampleKey(TypeHistogram, name, labels)
|
||||
r.samples[key] = Sample{
|
||||
Name: name,
|
||||
Type: TypeHistogram,
|
||||
Labels: cloneLabels(labels),
|
||||
Value: value,
|
||||
Float: float,
|
||||
FloatValue: floatValue,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) AddGauge(name string, labels map[string]string, delta int64) {
|
||||
if r == nil || name == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ensureSamplesLocked()
|
||||
|
||||
key := sampleKey(TypeGauge, name, labels)
|
||||
sample := r.samples[key]
|
||||
if sample.Name == "" {
|
||||
sample = Sample{Name: name, Type: TypeGauge, Labels: cloneLabels(labels)}
|
||||
}
|
||||
sample.Value += delta
|
||||
r.samples[key] = sample
|
||||
}
|
||||
|
||||
func (r *Registry) Snapshot() []Sample {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.RLock()
|
||||
out := make([]Sample, 0, len(r.samples))
|
||||
for _, sample := range r.samples {
|
||||
sample.Labels = cloneLabels(sample.Labels)
|
||||
out = append(out, sample)
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
if out[i].Type != out[j].Type {
|
||||
return out[i].Type < out[j].Type
|
||||
}
|
||||
return labelsKey(out[i].Labels) < labelsKey(out[j].Labels)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registry) ensureSamplesLocked() {
|
||||
if r.samples == nil {
|
||||
r.samples = make(map[string]Sample)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleKey(sampleType Type, name string, labels map[string]string) string {
|
||||
return string(sampleType) + "\x00" + name + "\x00" + labelsKey(labels)
|
||||
}
|
||||
|
||||
func labelsKey(labels map[string]string) string {
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(labels))
|
||||
for key := range labels {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b strings.Builder
|
||||
for _, key := range keys {
|
||||
b.WriteString(key)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(labels[key])
|
||||
b.WriteByte('\x00')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func cloneLabels(labels map[string]string) map[string]string {
|
||||
if len(labels) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(labels))
|
||||
for key, value := range labels {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var metricNameRE = regexp.MustCompile(`[^a-zA-Z0-9_:]`)
|
||||
var labelNameRE = regexp.MustCompile(`[^a-zA-Z0-9_]`)
|
||||
|
||||
type prometheusSample struct {
|
||||
name string
|
||||
typ Type
|
||||
labels string
|
||||
value int64
|
||||
float bool
|
||||
floatValue float64
|
||||
}
|
||||
|
||||
func Handler(registry *Registry) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
ObserveRuntime(registry)
|
||||
registry.RunObservers()
|
||||
WritePrometheus(w, registry.Snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func WritePrometheus(w interface{ Write([]byte) (int, error) }, samples []Sample) {
|
||||
seenTypes := make(map[string]Type)
|
||||
for _, sample := range normalizeSamples(samples) {
|
||||
// Histogram 多个子样本共享同一 base 名字 (<base>_bucket / _count / _sum),
|
||||
// TYPE 标注必须按 family 去重;其他类型按完整 metric 名去重保持原行为。
|
||||
typeKey := sample.name
|
||||
if sample.typ == TypeHistogram {
|
||||
typeKey = histogramFamily(sample.name)
|
||||
}
|
||||
if existing, ok := seenTypes[typeKey]; ok {
|
||||
if existing != sample.typ {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
seenTypes[typeKey] = sample.typ
|
||||
_, _ = fmt.Fprintf(w, "# TYPE %s %s\n", typeKey, prometheusType(sample.typ))
|
||||
}
|
||||
if sample.float {
|
||||
// 用 %g 而非 %f:histogram 的 sum 既要避免 1.0e-06 这种科学计数,
|
||||
// 又要避免拖一长串无意义的零;%g 在大多数 latency 场景下输出可读小数。
|
||||
_, _ = fmt.Fprintf(w, "%s%s %g\n", sample.name, sample.labels, sample.floatValue)
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(w, "%s%s %d\n", sample.name, sample.labels, sample.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// histogramFamily 把 <base>_bucket / <base>_count / <base>_sum
|
||||
// 还原成 <base>,用于 TYPE 标注去重。未识别后缀时返回原名,等价于"该样本自己就是 family"。
|
||||
func histogramFamily(name string) string {
|
||||
for _, suffix := range []string{"_sum", "_count", "_bucket"} {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func normalizeSamples(samples []Sample) []prometheusSample {
|
||||
merged := make(map[string]prometheusSample, len(samples))
|
||||
for _, sample := range samples {
|
||||
name := normalizeName(sample.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
labels := formatLabels(sample.Labels)
|
||||
key := string(sample.Type) + "\x00" + name + "\x00" + labels
|
||||
current := merged[key]
|
||||
if current.name == "" {
|
||||
current = prometheusSample{name: name, typ: sample.Type, labels: labels}
|
||||
}
|
||||
// Histogram observers 写入的是绝对值(替换语义,跟 gauge 一致),不能累加;
|
||||
// 否则 bucket / count / sum 会被多次叠加。Gauge 同理。
|
||||
switch sample.Type {
|
||||
case TypeGauge, TypeHistogram:
|
||||
current.value = sample.Value
|
||||
current.float = sample.Float
|
||||
current.floatValue = sample.FloatValue
|
||||
default:
|
||||
current.value += sample.Value
|
||||
}
|
||||
merged[key] = current
|
||||
}
|
||||
out := make([]prometheusSample, 0, len(merged))
|
||||
for _, sample := range merged {
|
||||
out = append(out, sample)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].name != out[j].name {
|
||||
return out[i].name < out[j].name
|
||||
}
|
||||
if out[i].typ != out[j].typ {
|
||||
return out[i].typ < out[j].typ
|
||||
}
|
||||
return out[i].labels < out[j].labels
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func prometheusType(t Type) string {
|
||||
switch t {
|
||||
case TypeGauge:
|
||||
return "gauge"
|
||||
case TypeHistogram:
|
||||
return "histogram"
|
||||
default:
|
||||
return "counter"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeName(name string) string {
|
||||
return normalizeIdentifier(name, metricNameRE, true)
|
||||
}
|
||||
|
||||
func normalizeLabelName(name string) string {
|
||||
return normalizeIdentifier(name, labelNameRE, false)
|
||||
}
|
||||
|
||||
func normalizeIdentifier(name string, invalid *regexp.Regexp, allowColonFirst bool) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = invalid.ReplaceAllString(name, "_")
|
||||
name = strings.Trim(name, "_")
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
first := name[0]
|
||||
if isAlpha(first) || first == '_' || (allowColonFirst && first == ':') {
|
||||
return name
|
||||
}
|
||||
return "_" + name
|
||||
}
|
||||
|
||||
func isAlpha(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
|
||||
}
|
||||
|
||||
func formatLabels(labels map[string]string) string {
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
normalized := make(map[string]string, len(labels))
|
||||
keys := make([]string, 0, len(labels))
|
||||
originalKeys := make([]string, 0, len(labels))
|
||||
for key := range labels {
|
||||
originalKeys = append(originalKeys, key)
|
||||
}
|
||||
sort.Strings(originalKeys)
|
||||
for _, key := range originalKeys {
|
||||
name := normalizeLabelName(key)
|
||||
if name != "" {
|
||||
if _, exists := normalized[name]; exists {
|
||||
continue
|
||||
}
|
||||
normalized[name] = labels[key]
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteByte('{')
|
||||
for idx, key := range keys {
|
||||
if idx > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(key)
|
||||
b.WriteString(`="`)
|
||||
b.WriteString(escapeLabel(normalized[key]))
|
||||
b.WriteByte('"')
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeLabel(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, "\n", `\n`)
|
||||
value = strings.ReplaceAll(value, `"`, `\"`)
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package metrics
|
||||
|
||||
import "runtime"
|
||||
|
||||
func ObserveRuntime(registry *Registry) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
var stats runtime.MemStats
|
||||
runtime.ReadMemStats(&stats)
|
||||
|
||||
registry.SetGauge("go_goroutines", nil, int64(runtime.NumGoroutine()))
|
||||
registry.SetGauge("go_heap_alloc_bytes", nil, Int64FromUint64(stats.HeapAlloc))
|
||||
registry.SetGauge("go_heap_inuse_bytes", nil, Int64FromUint64(stats.HeapInuse))
|
||||
registry.SetGauge("go_heap_objects", nil, Int64FromUint64(stats.HeapObjects))
|
||||
registry.SetGauge("go_gc_next_bytes", nil, Int64FromUint64(stats.NextGC))
|
||||
registry.SetGauge("go_gc_cycles_total", nil, int64(stats.NumGC))
|
||||
registry.SetGauge("go_gc_pause_total_ns", nil, Int64FromUint64(stats.PauseTotalNs))
|
||||
registry.SetGauge("go_gc_last_pause_ns", nil, Int64FromUint64(lastPauseNS(stats)))
|
||||
}
|
||||
|
||||
func lastPauseNS(stats runtime.MemStats) uint64 {
|
||||
if stats.NumGC == 0 {
|
||||
return 0
|
||||
}
|
||||
return stats.PauseNs[(stats.NumGC+255)%256]
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Scope struct {
|
||||
registry *Registry
|
||||
prefix string
|
||||
labels map[string]string
|
||||
}
|
||||
|
||||
type DumpOptions struct {
|
||||
Level slog.Level
|
||||
Message string
|
||||
MaxSamples int
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
func (r *Registry) Scope(parts ...string) Scope {
|
||||
return Scope{registry: r, prefix: JoinName(parts...)}
|
||||
}
|
||||
|
||||
func (s Scope) Scope(parts ...string) Scope {
|
||||
prefix := JoinName(s.prefix, JoinName(parts...))
|
||||
return Scope{registry: s.registry, prefix: prefix, labels: cloneLabels(s.labels)}
|
||||
}
|
||||
|
||||
func (s Scope) WithLabels(labels map[string]string) Scope {
|
||||
s.labels = mergeLabels(s.labels, labels)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s Scope) Name(parts ...string) string {
|
||||
return JoinName(s.prefix, JoinName(parts...))
|
||||
}
|
||||
|
||||
func (s Scope) Labels(labels map[string]string) map[string]string {
|
||||
return mergeLabels(s.labels, labels)
|
||||
}
|
||||
|
||||
func (s Scope) Inc(name string, labels map[string]string) {
|
||||
s.Add(name, labels, 1)
|
||||
}
|
||||
|
||||
func (s Scope) Add(name string, labels map[string]string, delta int64) {
|
||||
if s.registry == nil {
|
||||
return
|
||||
}
|
||||
s.registry.Add(s.Name(name), s.Labels(labels), delta)
|
||||
}
|
||||
|
||||
func (s Scope) SetCounter(name string, labels map[string]string, value int64) {
|
||||
if s.registry == nil {
|
||||
return
|
||||
}
|
||||
s.registry.SetCounter(s.Name(name), s.Labels(labels), value)
|
||||
}
|
||||
|
||||
func (s Scope) SetGauge(name string, labels map[string]string, value int64) {
|
||||
if s.registry == nil {
|
||||
return
|
||||
}
|
||||
s.registry.SetGauge(s.Name(name), s.Labels(labels), value)
|
||||
}
|
||||
|
||||
func (s Scope) AddGauge(name string, labels map[string]string, delta int64) {
|
||||
if s.registry == nil {
|
||||
return
|
||||
}
|
||||
s.registry.AddGauge(s.Name(name), s.Labels(labels), delta)
|
||||
}
|
||||
|
||||
func JoinName(parts ...string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
for _, field := range strings.FieldsFunc(part, func(r rune) bool {
|
||||
return r == '.' || r == '/' || r == '-' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
}) {
|
||||
field = strings.Trim(field, "_")
|
||||
if field != "" {
|
||||
out = append(out, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "_")
|
||||
}
|
||||
|
||||
func (r *Registry) DumpToLogger(ctx context.Context, logger *slog.Logger, opts DumpOptions) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
msg := strings.TrimSpace(opts.Message)
|
||||
if msg == "" {
|
||||
msg = "metrics snapshot"
|
||||
}
|
||||
r.RunObservers()
|
||||
samples := r.Snapshot()
|
||||
sampleCount := len(samples)
|
||||
if opts.MaxSamples > 0 && len(samples) > opts.MaxSamples {
|
||||
samples = samples[:opts.MaxSamples]
|
||||
}
|
||||
attrs := []slog.Attr{
|
||||
slog.Int("sample_count", sampleCount),
|
||||
slog.Any("samples", samples),
|
||||
}
|
||||
for key, value := range opts.Labels {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, slog.String(key, value))
|
||||
}
|
||||
logger.LogAttrs(ctx, opts.Level, msg, attrs...)
|
||||
}
|
||||
|
||||
func mergeLabels(base, extra map[string]string) map[string]string {
|
||||
if len(base) == 0 && len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(base)+len(extra))
|
||||
for key, value := range base {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
for key, value := range extra {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package metrics
|
||||
|
||||
import "time"
|
||||
|
||||
func (r *Registry) TimeStatsThreshold(name string, labels map[string]string, elapsed time.Duration, threshold time.Duration) {
|
||||
if r == nil || name == "" {
|
||||
return
|
||||
}
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
base := JoinName(name)
|
||||
r.Inc(JoinName(base, "total"), labels)
|
||||
r.SetGauge(JoinName(base, "last_ns"), labels, elapsed.Nanoseconds())
|
||||
if threshold > 0 && elapsed >= threshold {
|
||||
r.Inc(JoinName(base, "slow_total"), labels)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Scope) TimeStatsThreshold(name string, labels map[string]string, elapsed time.Duration, threshold time.Duration) {
|
||||
if s.registry == nil {
|
||||
return
|
||||
}
|
||||
s.registry.TimeStatsThreshold(s.Name(name), s.Labels(labels), elapsed, threshold)
|
||||
}
|
||||
Reference in new issue
Block a user