// Package metrics renders a Prometheus text exposition by hand. // // A client library would buy registration, collectors and concurrency handling // that a snapshot of ~200 gauges does not need — the whole format is three line // shapes, and this way the metric names can come from the payload at runtime. package metrics import ( "fmt" "math" "sort" "strconv" "strings" "git.tinetunnel.com/thistine/byd-exporter/internal/jsonv" ) // Labels is a metric's label set. Empty values are dropped on render. type Labels map[string]string type sample struct { labels Labels value float64 } // Set is one scrape's worth of gauges, grouped by metric name so that a name // shared across vehicles emits a single TYPE line. type Set struct { byName map[string][]sample } func New() *Set { return &Set{byName: map[string][]sample{}} } func (s *Set) Add(name string, labels Labels, value float64) { s.byName[name] = append(s.byName[name], sample{labels, value}) } // AddPayload emits every numeric field of a BYD payload as prefix + snake(key). // // Nothing here is a hand-maintained field list: BYD changes the realtime // payload between firmware versions, and new fields should appear on their own. // Non-numeric fields (strings, nested objects) are skipped — a metric can only // hold a float. func (s *Set) AddPayload(prefix string, labels Labels, payload map[string]any) { for k, v := range payload { f, ok := jsonv.Num(v) if !ok { continue } if name := prefix + Snake(k); ValidName(name) { s.Add(name, labels, f) } } } // Len reports how many samples the set holds. func (s *Set) Len() int { n := 0 for _, samples := range s.byName { n += len(samples) } return n } // String renders the exposition. Names are sorted, and samples within a name // are sorted by vin, so the output is stable between scrapes and diffable. func (s *Set) String() string { names := make([]string, 0, len(s.byName)) for n := range s.byName { names = append(names, n) } sort.Strings(names) var b strings.Builder for _, n := range names { fmt.Fprintf(&b, "# TYPE %s gauge\n", n) samples := s.byName[n] sort.SliceStable(samples, func(i, j int) bool { return samples[i].labels["vin"] < samples[j].labels["vin"] }) for _, sm := range samples { b.WriteString(n) b.WriteString(formatLabels(sm.labels)) b.WriteByte(' ') b.WriteString(formatValue(sm.value)) b.WriteByte('\n') } } return b.String() } // formatValue keeps whole numbers readable: a unix timestamp should render as // 1757000000, not 1.757e+09. Both are valid Prometheus, one is greppable. func formatValue(v float64) string { if v == math.Trunc(v) && math.Abs(v) < 1e15 { return strconv.FormatInt(int64(v), 10) } return strconv.FormatFloat(v, 'g', -1, 64) } func formatLabels(labels Labels) string { keys := make([]string, 0, len(labels)) for k, v := range labels { if v != "" { keys = append(keys, k) } } if len(keys) == 0 { return "" } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { // Quotes are added here rather than with %q, which would escape the // backslashes escapeLabel just added. parts = append(parts, k+`="`+escapeLabel(labels[k])+`"`) } return "{" + strings.Join(parts, ",") + "}" } func escapeLabel(v string) string { return strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(v) } // ValidName reports whether s is a legal Prometheus metric name. func ValidName(s string) bool { if s == "" { return false } for i, c := range s { switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_': case c >= '0' && c <= '9' && i > 0: default: return false } } return true } // Snake converts camelCase to snake_case: "leftFrontTirepressure" becomes // "left_front_tirepressure" and "evSOC" becomes "ev_soc". func Snake(s string) string { var b strings.Builder runes := []rune(s) for i, r := range runes { isUpper := r >= 'A' && r <= 'Z' if isUpper && i > 0 { prev := runes[i-1] prevLowerOrDigit := (prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') // A capital starting a new word inside a run of capitals, as the C // in "evSOCValue". capThenWord := prev >= 'A' && prev <= 'Z' && i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' if prevLowerOrDigit || capThenWord { b.WriteByte('_') } } if isUpper { r += 'a' - 'A' } b.WriteRune(r) } return b.String() }