2026-08-15 13:30:35 +08:00
|
|
|
// Package telemetry 提供进程内可观测能力:Counter / Gauge / Histogram,
|
|
|
|
|
// 以及 Prometheus 文本格式导出。
|
|
|
|
|
//
|
|
|
|
|
// 设计原则:
|
|
|
|
|
// - 零外部依赖,只使用标准库;
|
|
|
|
|
// - label key 在指标注册时锁定,运行期不可新增(避免 cardinality 爆炸);
|
|
|
|
|
// - 所有并发安全由实现保证,调用方无需加锁;
|
|
|
|
|
// - Meter 是高层入口,NoopMeter 用于测试。
|
|
|
|
|
package telemetry
|
|
|
|
|
|
2026-08-29 21:42:57 +08:00
|
|
|
import (
|
|
|
|
|
"crypto/sha1"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"sort"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
2026-08-15 13:30:35 +08:00
|
|
|
|
|
|
|
|
// Labels 是指标附加的标签集合。Value 在序列化时会按 Prometheus 规范转义。
|
|
|
|
|
type Labels map[string]string
|
|
|
|
|
|
2026-08-29 21:42:57 +08:00
|
|
|
// SpeakerLabel 把 speaker ID 转成不可逆的稳定短哈希,作为指标 label。
|
|
|
|
|
// 目的:保护火山复刻音色资产(speaker ID 是用户付费 / 隐私敏感);
|
|
|
|
|
// 同时仍能按 voice 聚合观测(同 speaker → 同 label)。
|
|
|
|
|
//
|
|
|
|
|
// 算法: sha1(s)[:8] = 32 bits 空间;典型 <100 个 voice 场景无碰撞风险。
|
|
|
|
|
// 空串返回 "unknown",避免 /metrics label 出现空值 (Prometheus 禁止空 label)。
|
|
|
|
|
//
|
|
|
|
|
// 注意: 这是**不可逆**哈希,不是加密;不可用于需要还原原始 speaker 的场景。
|
|
|
|
|
// Admin UI 想要看原名时,通过 /api/voices 拿 name 字段对照。
|
|
|
|
|
func SpeakerLabel(s string) string {
|
|
|
|
|
if s == "" {
|
|
|
|
|
return "unknown"
|
|
|
|
|
}
|
|
|
|
|
sum := sha1.Sum([]byte(s))
|
|
|
|
|
return hex.EncodeToString(sum[:])[:8]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MaskSpeaker 把 speaker ID 部分打码用于日志输出。
|
|
|
|
|
// - 空 → "***"
|
|
|
|
|
// - 长度 ≤ 4 → 全打码
|
|
|
|
|
// - 其它 → 前 4 + **** + 后 4 (保留前缀便于肉眼区分 "S_xx 开头" vs "BV001_...")
|
|
|
|
|
// 例子: "S_G8tEKnaJ1" → "S_G8****naJ1"
|
|
|
|
|
func MaskSpeaker(s string) string {
|
2026-09-12 14:34:06 +08:00
|
|
|
return maskWithAffix(s, "(未设置)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MaskResourceID 把火山 TTS 资源 ID 部分打码用于日志输出。
|
|
|
|
|
// 资源 ID 同样属于用户付费/敏感资产(指向 V3 复刻项目),与 speaker 走同一规则。
|
|
|
|
|
// - 空 → "(未设置)"
|
|
|
|
|
// - 长度 ≤ 4 → 全打码
|
|
|
|
|
// - 其它 → 前 4 + **** + 后 4
|
|
|
|
|
// 例子: "volc.megatts.icl" → "volc****.icl"; "seed-icl-2.0" → "seed****2.0"
|
|
|
|
|
func MaskResourceID(s string) string {
|
|
|
|
|
return maskWithAffix(s, "(未设置)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maskWithAffix 共用的"前 4 + **** + 后 4"打码逻辑,空串返回 emptyLabel。
|
|
|
|
|
func maskWithAffix(s, emptyLabel string) string {
|
2026-08-29 21:42:57 +08:00
|
|
|
if s == "" {
|
2026-09-12 14:34:06 +08:00
|
|
|
return emptyLabel
|
2026-08-29 21:42:57 +08:00
|
|
|
}
|
|
|
|
|
if len(s) <= 4 {
|
|
|
|
|
return strings.Repeat("*", len(s))
|
|
|
|
|
}
|
|
|
|
|
// 找前 4 字符中第一个非 [A-Za-z0-9_] 字符做截断,避免截到奇怪位置
|
2026-09-12 14:34:06 +08:00
|
|
|
// (虽然火山 ID 实际都是字母数字组合,这里保险)
|
2026-08-29 21:42:57 +08:00
|
|
|
prefix := s[:4]
|
|
|
|
|
suffix := s[len(s)-4:]
|
|
|
|
|
return prefix + "****" + suffix
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 13:30:35 +08:00
|
|
|
// labelKey 计算一组标签的稳定 key,用于在内部 map 中唯一定位 child。
|
|
|
|
|
// 缺失或多余的 label 一律视为空串,以保证 child 数量与 label 名集合一致。
|
|
|
|
|
func labelKey(names []string, labels Labels) string {
|
|
|
|
|
if len(names) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
parts := make([]string, 0, len(names)*2)
|
|
|
|
|
for _, n := range names {
|
|
|
|
|
parts = append(parts, n, labels[n])
|
|
|
|
|
}
|
|
|
|
|
return joinLabelParts(parts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func joinLabelParts(parts []string) string {
|
|
|
|
|
out := make([]byte, 0, 16*len(parts))
|
|
|
|
|
for i, p := range parts {
|
|
|
|
|
if i > 0 {
|
|
|
|
|
out = append(out, 0)
|
|
|
|
|
}
|
|
|
|
|
out = append(out, p...)
|
|
|
|
|
}
|
|
|
|
|
return string(out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sortedKeys 返回按字典序排列的 key,用于导出时输出稳定顺序。
|
|
|
|
|
func sortedKeys(m map[string]string) []string {
|
|
|
|
|
keys := make([]string, 0, len(m))
|
|
|
|
|
for k := range m {
|
|
|
|
|
keys = append(keys, k)
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(keys)
|
|
|
|
|
return keys
|
|
|
|
|
}
|