Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import (
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/telemetry"
_ "github.com/joho/godotenv/autoload"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/samber/lo"
"github.com/spf13/cast"
Expand All @@ -46,13 +48,14 @@ const (
)

var (
log = util.NewLogger("main")
cfgFile string
cfgDatabase string
customCssFile string
ignoreEmpty = "" // ignore empty keys
ignoreLogs = []string{"log"} // ignore log messages, including warn/error
ignoreMqtt = []string{"log", "auth", "releaseNotes"} // excessive size may crash certain brokers
log = util.NewLogger("main")
cfgFile string
cfgDatabase string
customCssFile string
ignoreEmpty = "" // ignore empty keys
ignoreLogs = []string{"log"} // ignore log messages, including warn/error
ignoreMqtt = []string{"log", "auth", "releaseNotes"} // excessive size may crash certain brokers
ignorePrometheus = []string{"log", "auth", "releaseNotes", "forecast"} // avoid excessive metric/label cardinality

viper *vpr.Viper

Expand Down Expand Up @@ -267,8 +270,14 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.ApiReady, Val: false}

// metrics
var promMetrics *server.Prometheus
if viper.GetBool("metrics") {
httpd.Router().Handle("/metrics", promhttp.Handler())
promRegistry := prometheus.NewRegistry()
promRegistry.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))

promMetrics = server.NewPrometheus(promRegistry)

httpd.Router().Handle("/metrics", promhttp.HandlerFor(promRegistry, promhttp.HandlerOpts{}))
}

// pprof
Expand Down Expand Up @@ -341,6 +350,11 @@ func runRoot(cmd *cobra.Command, args []string) {
}
}

// feed prometheus exporter with site state
if err == nil && promMetrics != nil {
go promMetrics.Run(site, pipe.NewDropper(append(ignorePrometheus, ignoreEmpty)...).Pipe(tee.Attach()))
}

// announce on mDNS
if err == nil {
if err := configureMDNS(conf.Network); err != nil {
Expand Down
289 changes: 289 additions & 0 deletions server/prometheus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
package server

import (
"fmt"
"maps"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/util"
"github.com/prometheus/client_golang/prometheus"
"github.com/samber/lo"
)

const prometheusPrefix = "evcc_"

// invalidPrometheusChars matches runs of characters that are not valid in a
// Prometheus metric name. Underscores are matched too so that separators from
// snake_case conversion and nested-key flattening collapse into a single
// underscore (e.g. "grid__power" -> "grid_power").
var invalidPrometheusChars = regexp.MustCompile(`[^a-zA-Z0-9]+`)

// promSample is the last known value for a single metric+label combination
type promSample struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring how labels are stored and used so that ordered label name/value slices are created once and reused, rather than rebuilding and reordering label maps on every scrape.

You can reduce complexity and a potential correctness risk around labels by avoiding rebuilding label names/values and re‑deriving order on every scrape.

Right now you:

  • Build a labelSignature by sorting a map[string]string (stable order),
  • Store promSample with a map[string]string,
  • In Collect you reconstruct labelNames/labelValues from the map with non‑deterministic ordering.

This makes label ordering implicit and fragile and forces extra work on every scrape.

You can simplify this by:

  1. Storing ordered label names/values once when setting a sample.
  2. Deriving the signature from those slices.
  3. Using those slices directly in Collect.

Suggested refactor

Change promSample to hold ordered slices:

type promSample struct {
    value       float64
    labelNames  []string
    labelValues []string
}

Add a helper to normalize labels once:

func normalizeLabels(labels prometheus.Labels) ([]string, []string) {
    if len(labels) == 0 {
        return nil, nil
    }

    names := make([]string, 0, len(labels))
    for k := range labels {
        names = append(names, k)
    }
    sort.Strings(names)

    values := make([]string, len(names))
    for i, k := range names {
        values[i] = labels[k]
    }

    return names, values
}

func labelSignature(names, values []string) string {
    var b strings.Builder
    for i, name := range names {
        b.WriteString(name)
        b.WriteByte('=')
        b.WriteString(values[i])
        b.WriteByte(';')
    }
    return b.String()
}

Update set to compute everything once:

func (p *Prometheus) set(name string, value float64, labels prometheus.Labels) {
    p.mu.Lock()
    defer p.mu.Unlock()

    if p.samples[name] == nil {
        p.samples[name] = make(map[string]promSample)
    }

    names, vals := normalizeLabels(labels)
    sig := labelSignature(names, vals)

    p.samples[name][sig] = promSample{
        value:       value,
        labelNames:  names,
        labelValues: vals,
    }
}

Collect can then be much simpler and avoids recomputing/scrambling label order:

func (p *Prometheus) Collect(ch chan<- prometheus.Metric) {
    p.mu.Lock()
    defer p.mu.Unlock()

    for name, samples := range p.samples {
        for _, s := range samples {
            desc := prometheus.NewDesc(name, "evcc: "+name, s.labelNames, nil)
            m, err := prometheus.NewConstMetric(desc, prometheus.GaugeValue, s.value, s.labelValues...)
            if err != nil {
                p.log.ERROR.Printf("collect %s: %v", name, err)
                continue
            }
            ch <- m
        }
    }
}

This keeps all existing functionality but:

  • Removes the need for a label map in promSample,
  • Ensures stable label ordering (and thus consistent series identity),
  • Eliminates repeated map iteration / slice construction in Collect,
  • Tightens the path from input labels → signature → output metric.

value float64
labels prometheus.Labels
}

// Prometheus is a Prometheus exporter. It mirrors the internal evcc state
// (the same values published via MQTT/InfluxDB) as an in-memory metric
// snapshot that is served to Prometheus on scrape via /metrics.
type Prometheus struct {
log *util.Logger

mu sync.Mutex
// samples maps metric name -> label signature -> sample
samples map[string]map[string]promSample
}

// NewPrometheus creates a Prometheus exporter and registers it with reg
func NewPrometheus(reg prometheus.Registerer) *Prometheus {
p := &Prometheus{
log: util.NewLogger("prometheus"),
samples: make(map[string]map[string]promSample),
}

reg.MustRegister(p)

return p
}

// metricName converts an evcc key such as "loadpoint_chargePower" into a
// valid, idiomatic Prometheus metric name such as "evcc_loadpoint_charge_power"
func metricName(key string) string {
var b strings.Builder
b.WriteString(prometheusPrefix)

for i, r := range key {
switch {
case r >= 'A' && r <= 'Z':
if i > 0 {
b.WriteByte('_')
}
b.WriteRune(r - 'A' + 'a')
default:
b.WriteRune(r)
}
}

return invalidPrometheusChars.ReplaceAllString(b.String(), "_")
}

// labelSignature returns a stable string representation of a label set,
// used as a map key to identify a unique metric+label combination
func labelSignature(labels prometheus.Labels) string {
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)

var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteByte('=')
b.WriteString(labels[k])
b.WriteByte(';')
}

return b.String()
}

// set stores/overwrites the current value for a metric+label combination
func (p *Prometheus) set(name string, value float64, labels prometheus.Labels) {
p.mu.Lock()
defer p.mu.Unlock()

if p.samples[name] == nil {
p.samples[name] = make(map[string]promSample)
}

p.samples[name][labelSignature(labels)] = promSample{value: value, labels: labels}
}

// record flattens an arbitrary evcc value (as also produced for MQTT/InfluxDB)
// into one or more numeric Prometheus samples. Strings are not exported as
// metrics- their value has no useful numeric representation.
func (p *Prometheus) record(key string, val any, labels prometheus.Labels) {
if val == nil || lo.IsNil(val) {
return
}

switch v := val.(type) {
case string:
return

case bool:
p.set(metricName(key), boolToFloat(v), labels)
return

case int:
p.set(metricName(key), float64(v), labels)
return

case int64:
p.set(metricName(key), float64(v), labels)
return

case float64:
p.set(metricName(key), v, labels)
return

case time.Time:
if !v.IsZero() {
p.set(metricName(key), float64(v.Unix()), labels)
}
return

case time.Duration:
p.set(metricName(key), v.Seconds(), labels)
return

case []float64:
p.recordPhases(key, v, labels)
return

case [3]float64:
p.recordPhases(key, v[:], labels)
return
}

if mm, ok := val.(api.StructMarshaler); ok {
if d, err := mm.MarshalStruct(); err == nil {
p.record(key, d, labels)
} else {
p.log.ERROR.Printf("marshal struct: %v", err)
}
return
}

// BytesMarshaler (raw/binary payloads) has no numeric representation
if _, ok := val.(api.BytesMarshaler); ok {
return
}

rv := reflect.ValueOf(val)

switch rv.Kind() {
case reflect.Pointer:
if !rv.IsNil() {
p.record(key, rv.Elem().Interface(), labels)
}

case reflect.Struct:
typ := rv.Type()
for i := range typ.NumField() {
if f := typ.Field(i); f.IsExported() {
p.record(key+"_"+f.Name, rv.Field(i).Interface(), labels)
}
}

case reflect.Slice, reflect.Array:
for i := range rv.Len() {
Comment on lines +188 to +189

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Slice/array iteration also uses range over an int, which will not compile; switch to a standard index loop.

You can implement it like this:

case reflect.Slice, reflect.Array:
    for i := 0; i < rv.Len(); i++ {
        l := maps.Clone(labels)
        l["id"] = strconv.Itoa(i + 1)
        p.record(key, rv.Index(i).Interface(), l)
    }

rv.Len() returns an int, so it must be used as the loop bound, not as the operand to range.

l := maps.Clone(labels)
l["id"] = strconv.Itoa(i + 1)
p.record(key, rv.Index(i).Interface(), l)
}

case reflect.Map:
for _, k := range rv.MapKeys() {
p.record(fmt.Sprintf("%s_%v", key, k.Interface()), rv.MapIndex(k).Interface(), labels)
}

case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
p.set(metricName(key), float64(rv.Int()), labels)

case reflect.Float32, reflect.Float64:
p.set(metricName(key), rv.Float(), labels)

case reflect.Bool:
p.set(metricName(key), boolToFloat(rv.Bool()), labels)
}
}

// recordPhases records a 3-phase value both per-phase (with a "phase" label)
// and as an aggregated sum, matching the MQTT/InfluxDB phase handling
func (p *Prometheus) recordPhases(key string, phases []float64, labels prometheus.Labels) {
if len(phases) != 3 {
return
}

var total float64
for i, v := range phases {
total += v

l := maps.Clone(labels)
l["phase"] = strconv.Itoa(i + 1)
p.set(metricName(key), v, l)
}

p.set(metricName(key), total, labels)
}

func boolToFloat(b bool) float64 {
if b {
return 1
}
return 0
}

// Run starts the Prometheus exporter, consuming site state updates from in
// and mirroring them into the in-memory metric snapshot
func (p *Prometheus) Run(site site.API, in <-chan util.Param) {
for param := range in {
labels := prometheus.Labels{}

key := param.Key
if param.Loadpoint != nil {
if lps := site.Loadpoints(); *param.Loadpoint < len(lps) {
lp := lps[*param.Loadpoint]
labels["loadpoint"] = lp.GetTitle()
if v := lp.GetVehicle(); v != nil {
labels["vehicle"] = v.GetTitle()
}
}
key = "loadpoint_" + param.Key
}

p.record(key, param.Val, labels)
}
}

// Describe implements prometheus.Collector. Metrics are dynamic and
// depend on the configured devices, so no fixed descriptors are provided
// upfront- this registers Prometheus as an "unchecked" collector.
func (p *Prometheus) Describe(chan<- *prometheus.Desc) {}

// Collect implements prometheus.Collector
func (p *Prometheus) Collect(ch chan<- prometheus.Metric) {
p.mu.Lock()
defer p.mu.Unlock()

for name, samples := range p.samples {
for _, s := range samples {
labelNames := make([]string, 0, len(s.labels))
labelValues := make([]string, 0, len(s.labels))
for k, v := range s.labels {
labelNames = append(labelNames, k)
labelValues = append(labelValues, v)
}

desc := prometheus.NewDesc(name, "evcc: "+name, labelNames, nil)

m, err := prometheus.NewConstMetric(desc, prometheus.GaugeValue, s.value, labelValues...)
if err != nil {
p.log.ERROR.Printf("collect %s: %v", name, err)
continue
}

ch <- m
}
}
}
Loading
Loading