diff --git a/battery/battery.go b/battery/battery.go new file mode 100644 index 0000000..b741689 --- /dev/null +++ b/battery/battery.go @@ -0,0 +1,130 @@ +//go:build darwin + +package battery + +import ( + "math" + "time" +) + +type Battery struct { + // BatteryCellDisconnectCount is the number of times the battery cells have + // been disconnected. + BatteryCellDisconnectCount int + + // BuiltIn indicates if the battery is built-in or not. + BuiltIn bool + + // ChargeRateAmps is the current charge rate in mAh. Negative values indicate + // discharge, positive values indicate charging. + ChargeRateAmps int64 + + // ChargeRateWatts is the current charge rate in mWh. Negative values indicate + // discharge, positive values indicate charging. + ChargeRateWatts float64 + + // CurrentCapacityAmps is the current battery capacity in mAh. + CurrentCapacityAmps int + + // CurrentCapacityWatts is the current battery capacity in mWh. + CurrentCapacityWatts float64 + + // CurrentPercentage is the current battery capacity as a percentage. + CurrentPercentage int + + // CycleCount is the current cycle count. + CycleCount int + + // DesignCapacityAmps is the design capacity in mAh. + DesignCapacityAmps int + + // DesignCapacityWatts is the design capacity in mWh. + DesignCapacityWatts float64 + + // DeviceName is the battery device name. + DeviceName string + + // FullyCharged indicates if the battery is fully charged. + FullyCharged bool + + // Health is the battery health as a percentage (0-100%). + Health int + + // IsCharging indicates if the battery is currently charging. + IsCharging bool + + // MaxCapacityAmps is the maximum capacity in mAh. + MaxCapacityAmps int + + // MaxCapacityWatts is the maximum capacity in mWh. + MaxCapacityWatts float64 + + // Serial is the battery serial number. + Serial string + + // Temperature is the current temperature in °C. + Temperature float64 + + // TimeRemaining is the estimated time remaining until the battery is + // fully charged or discharged. + TimeRemaining time.Duration + + // Voltage is the current voltage in mV. + Voltage int64 +} + +func newBattery(b *batteryRaw) *Battery { + volts := float64(b.Voltage) / 1000 + + return &Battery{ + BatteryCellDisconnectCount: b.BatteryCellDisconnectCount, + BuiltIn: b.BuiltIn, + ChargeRateAmps: b.Amperage, + ChargeRateWatts: roundTo(float64(b.Amperage)*volts, 3), + CurrentCapacityAmps: b.CurrentCapacity, + CurrentCapacityWatts: roundTo(float64(b.CurrentCapacity)*volts, 3), + CurrentPercentage: b.CurrentPercentage, + CycleCount: b.CycleCount, + DesignCapacityAmps: b.DesignCapacity, + DesignCapacityWatts: roundTo(float64(b.DesignCapacity)*volts, 3), + DeviceName: b.DeviceName, + FullyCharged: b.FullyCharged, + Health: b.Health, + IsCharging: b.IsCharging, + MaxCapacityAmps: b.MaxCapacity, + MaxCapacityWatts: roundTo(float64(b.MaxCapacity)*volts, 3), + Serial: b.Serial, + Temperature: float64(b.Temperature) / 100, + TimeRemaining: time.Duration(b.TimeRemaining) * time.Minute, + Voltage: b.Voltage, + } +} + +func Get() (*Battery, error) { + batteriesRaw, err := getAllRaw() + if err != nil { + return nil, err + } + + return newBattery(batteriesRaw[0]), nil +} + +func GetAll() ([]*Battery, error) { + batteriesRaw, err := getAllRaw() + if err != nil { + return nil, err + } + + batteries := []*Battery{} + for _, b := range batteriesRaw { + batteries = append(batteries, newBattery(b)) + } + + return batteries, nil +} + +// roundTo rounds a float64 to 'places' decimal places +func roundTo(value float64, places int) float64 { + shift := math.Pow(10, float64(places)) + return math.Round(value*shift) / shift +} diff --git a/battery/ioreg.go b/battery/ioreg.go new file mode 100644 index 0000000..cd3d8c5 --- /dev/null +++ b/battery/ioreg.go @@ -0,0 +1,48 @@ +//go:build darwin + +package battery + +import ( + "os/exec" + + "howett.net/plist" +) + +// batteryRaw is the raw data structure returned by ioreg. +type batteryRaw struct { + Amperage int64 `plist:"Amperage"` + AvgTimeToEmpty int `plist:"AvgTimeToEmpty"` + AvgTimeToFull int `plist:"AvgTimeToFull"` + BatteryCellDisconnectCount int `plist:"BatteryCellDisconnectCount"` + BuiltIn bool `plist:"built-in"` + CurrentCapacity int `plist:"AppleRawCurrentCapacity"` + CurrentPercentage int `plist:"CurrentCapacity"` + CycleCount int `plist:"CycleCount"` + DesignCapacity int `plist:"DesignCapacity"` + DeviceName string `plist:"DeviceName"` + DesignCycleCount int `plist:"DesignCycleCount9C"` + ExternalConnected bool `plist:"ExternalConnected"` + FullyCharged bool `plist:"FullyCharged"` + Health int `plist:"MaxCapacity"` + IsCharging bool `plist:"IsCharging"` + MaxCapacity int `plist:"AppleRawMaxCapacity"` + Serial string `plist:"Serial"` + Temperature int `plist:"Temperature"` + TimeRemaining int `plist:"TimeRemaining"` + Voltage int64 `plist:"Voltage"` +} + +func getAllRaw() ([]*batteryRaw, error) { + b, err := exec.Command("ioreg", "-ra", "-c", "AppleSmartBattery").Output() + if err != nil { + return nil, err + } + + batteries := []*batteryRaw{} + _, err = plist.Unmarshal(b, &batteries) + if err != nil { + return nil, err + } + + return batteries, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8379e2b --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/jimeh/macos-battery-exporter + +go 1.21.4 + +require ( + github.com/prometheus/client_golang v1.17.0 + github.com/prometheus/common v0.45.0 + howett.net/plist v1.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + golang.org/x/sys v0.15.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5496113 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5b3ec63 --- /dev/null +++ b/main.go @@ -0,0 +1,117 @@ +//go:build darwin + +package main + +import ( + "flag" + "fmt" + "log" + "log/slog" + "os" + "strings" + + "github.com/jimeh/macos-battery-exporter/prom" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/expfmt" +) + +var ( + outputFlag = flag.String( + "o", "", "Output file to write to in Prometheus format", + ) + serverFlag = flag.Bool("s", false, "Run as a Prometheus metrics server") + bindFlag = flag.String( + "b", "127.0.0.1", "Bind address to run server on", + ) + portFlag = flag.Int("p", 9108, "Port to run server on") + namespaceFlag = flag.String("n", "node", "Namespace for metrics") + logLevelFlag = flag.String("l", "info", "Log level") +) + +func main() { + if err := mainE(); err != nil { + log.Fatal(err) + } +} + +func mainE() error { + flag.Parse() + + err := setupSLog(*logLevelFlag) + if err != nil { + return err + } + + if *serverFlag { + opts := prom.ServerOptions{ + Bind: *bindFlag, + Port: *portFlag, + } + + return prom.RunServer( + *namespaceFlag, + prometheus.DefaultRegisterer.(*prometheus.Registry), + opts, + ) + } else { + registry := prometheus.NewRegistry() + err := registry.Register(prom.NewCollector(*namespaceFlag)) + if err != nil { + return err + } + + gatherers := prometheus.Gatherers{registry} + metricFamilies, err := gatherers.Gather() + if err != nil { + return err + } + + var sb strings.Builder + for _, mf := range metricFamilies { + _, err := expfmt.MetricFamilyToText(&sb, mf) + if err != nil { + return err + } + } + + if *outputFlag != "" { + return writeToFile(sb.String(), *outputFlag) + } else { + fmt.Print(sb.String()) + } + } + + return nil +} + +func setupSLog(levelStr string) error { + var level slog.Level + err := level.UnmarshalText([]byte(levelStr)) + if err != nil { + return err + } + + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: level, + }) + logger := slog.New(handler) + + slog.SetDefault(logger) + + return nil +} + +func writeToFile(data, outputFile string) error { + file, err := os.Create(outputFile) + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString(data) + if err != nil { + return err + } + + return nil +} diff --git a/prom/collector.go b/prom/collector.go new file mode 100644 index 0000000..9012db8 --- /dev/null +++ b/prom/collector.go @@ -0,0 +1,350 @@ +//go:build darwin + +package prom + +import ( + "log/slog" + + "github.com/jimeh/macos-battery-exporter/battery" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + serialLabel = "serial" + deviceNameLabel = "device_name" + builtInLabel = "built_in" +) + +type Collector struct { + descInfo *prometheus.Desc + descBatteryCellDisconnectCount *prometheus.Desc + descChargeRateAmps *prometheus.Desc + descChargeRateWatts *prometheus.Desc + descCurrentCapacityAmps *prometheus.Desc + descCurrentCapacityWatts *prometheus.Desc + descCurrentPercentage *prometheus.Desc + descCycleCount *prometheus.Desc + descDesignCapacityAmps *prometheus.Desc + descDesignCapacityWatts *prometheus.Desc + descFullyCharged *prometheus.Desc + descHealth *prometheus.Desc + descIsCharging *prometheus.Desc + descMaxCapacityAmps *prometheus.Desc + descMaxCapacityWatts *prometheus.Desc + descTemperature *prometheus.Desc + descTimeRemaining *prometheus.Desc + descVoltage *prometheus.Desc +} + +var _ prometheus.Collector = &Collector{} + +func NewCollector(namespace string) *Collector { + c := &Collector{ + descInfo: prometheus.NewDesc( + prometheus.BuildFQName(namespace, "battery", "info"), + "Basic details about the battery.", + []string{serialLabel, deviceNameLabel, builtInLabel}, + nil, + ), + descBatteryCellDisconnectCount: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "cell_disconnect_count", + ), + "Total number of times a battery cell has been disconnected.", + []string{serialLabel}, + nil, + ), + descChargeRateAmps: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "charge_rate_amps", + ), + "Current charge rate in Ah.", + []string{serialLabel}, + nil, + ), + descChargeRateWatts: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "charge_rate_watts", + ), + "Current charge rate in Wh.", + []string{serialLabel}, + nil, + ), + descCurrentCapacityAmps: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "current_capacity_amps", + ), + "Current charge capacity in Ah.", + []string{serialLabel}, + nil, + ), + descCurrentCapacityWatts: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "current_capacity_watts", + ), + "Current charge capacity in Wh.", + []string{serialLabel}, + nil, + ), + descCurrentPercentage: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "current_percentage", + ), + "Current battery charge percentage.", + []string{serialLabel}, + nil, + ), + descCycleCount: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "cycle_count", + ), + "Current battery cycle count.", + []string{serialLabel}, + nil, + ), + descDesignCapacityAmps: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "design_capacity_amps", + ), + "Design capacity in Ah.", + []string{serialLabel}, + nil, + ), + descDesignCapacityWatts: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "design_capacity_watts", + ), + "Design capacity in Wh.", + []string{serialLabel}, + nil, + ), + descFullyCharged: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "fully_charged", + ), + "Indicates if the battery is fully charged.", + []string{serialLabel}, + nil, + ), + descHealth: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "health", + ), + "Battery health as a percentage (0-100%).", + []string{serialLabel}, + nil, + ), + descIsCharging: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "is_charging", + ), + "Indicates if the battery is currently charging.", + []string{serialLabel}, + nil, + ), + descMaxCapacityAmps: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "max_capacity_amps", + ), + "Design capacity in Ah.", + []string{serialLabel}, + nil, + ), + descMaxCapacityWatts: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "max_capacity_watts", + ), + "Design capacity in Wh.", + []string{serialLabel}, + nil, + ), + descTemperature: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "temperature_celsius", + ), + "Current battery temperature in °C.", + []string{serialLabel}, + nil, + ), + descTimeRemaining: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "time_remaining_seconds", + ), + "Estimated time remaining until battery is fully "+ + "charged or discharged.", + []string{serialLabel}, + nil, + ), + descVoltage: prometheus.NewDesc( + prometheus.BuildFQName( + namespace, "battery", "voltage_volts", + ), + "Current battery voltage in V.", + []string{serialLabel}, + nil, + ), + } + + return c +} + +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.descInfo + ch <- c.descBatteryCellDisconnectCount + ch <- c.descChargeRateAmps + ch <- c.descChargeRateWatts + ch <- c.descCurrentCapacityAmps + ch <- c.descCurrentCapacityWatts + ch <- c.descCurrentPercentage + ch <- c.descCycleCount + ch <- c.descDesignCapacityAmps + ch <- c.descDesignCapacityWatts + ch <- c.descFullyCharged + ch <- c.descHealth + ch <- c.descIsCharging + ch <- c.descMaxCapacityAmps + ch <- c.descMaxCapacityWatts + ch <- c.descTemperature + ch <- c.descTimeRemaining +} + +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + slog.Debug("collecting battery metrics") + batteries, err := battery.GetAll() + if err != nil { + slog.Error( + "failed to get battery details", + slog.String("error", err.Error()), + ) + return + } + + for _, battery := range batteries { + labels := []string{battery.Serial} + + ch <- prometheus.MustNewConstMetric( + c.descInfo, + prometheus.GaugeValue, + 1, + battery.Serial, battery.DeviceName, boolToString(battery.BuiltIn), + ) + ch <- prometheus.MustNewConstMetric( + c.descBatteryCellDisconnectCount, + prometheus.GaugeValue, + float64(battery.BatteryCellDisconnectCount), + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descChargeRateAmps, + prometheus.GaugeValue, + float64(battery.ChargeRateAmps)/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descChargeRateWatts, + prometheus.GaugeValue, + battery.ChargeRateWatts/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descCurrentCapacityAmps, + prometheus.GaugeValue, + float64(battery.CurrentCapacityAmps)/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descCurrentCapacityWatts, + prometheus.GaugeValue, + battery.CurrentCapacityWatts/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descCurrentPercentage, + prometheus.GaugeValue, + float64(battery.CurrentPercentage), + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descCycleCount, + prometheus.CounterValue, + float64(battery.CycleCount), + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descDesignCapacityAmps, + prometheus.GaugeValue, + float64(battery.DesignCapacityAmps)/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descDesignCapacityWatts, + prometheus.GaugeValue, + battery.DesignCapacityWatts/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descFullyCharged, + prometheus.GaugeValue, + boolToFloat64(battery.FullyCharged), + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descHealth, + prometheus.GaugeValue, + float64(battery.Health), + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descIsCharging, + prometheus.GaugeValue, + boolToFloat64(battery.IsCharging), + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descMaxCapacityAmps, + prometheus.GaugeValue, + float64(battery.MaxCapacityAmps)/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descMaxCapacityWatts, + prometheus.GaugeValue, + battery.MaxCapacityWatts/1000, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descTemperature, + prometheus.GaugeValue, + battery.Temperature, + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descTimeRemaining, + prometheus.GaugeValue, + battery.TimeRemaining.Seconds(), + labels..., + ) + ch <- prometheus.MustNewConstMetric( + c.descVoltage, + prometheus.GaugeValue, + float64(battery.Voltage)/1000, + labels..., + ) + } +} + +func boolToString(b bool) string { + if b { + return "true" + } + + return "false" +} + +func boolToFloat64(b bool) float64 { + if b { + return 1 + } + + return 0 +} diff --git a/prom/server.go b/prom/server.go new file mode 100644 index 0000000..ee5c96e --- /dev/null +++ b/prom/server.go @@ -0,0 +1,111 @@ +//go:build darwin + +package prom + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +type Registry interface { + prometheus.Registerer + prometheus.Gatherer +} + +type ServerOptions struct { + Bind string + Port int + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + Logger *slog.Logger +} + +type Server struct { + *http.Server + registry Registry + mux *http.ServeMux +} + +func NewServer(registry *prometheus.Registry, options ServerOptions) *Server { + if options.Bind == "" { + options.Bind = "127.0.0.1" + } + if options.Port == 0 { + options.Port = 9108 + } + if options.ReadTimeout == 0 { + options.ReadTimeout = 5 * time.Second + } + if options.WriteTimeout == 0 { + options.WriteTimeout = 10 * time.Second + } + if options.IdleTimeout == 0 { + options.IdleTimeout = 30 * time.Second + } + + mux := http.NewServeMux() + mux.Handle( + "/metrics", + promhttp.HandlerFor(registry, promhttp.HandlerOpts{}), + ) + + return &Server{ + mux: mux, + registry: registry, + Server: &http.Server{ + Addr: fmt.Sprintf("%s:%d", options.Bind, options.Port), + ReadTimeout: options.ReadTimeout, + WriteTimeout: options.WriteTimeout, + IdleTimeout: options.IdleTimeout, + Handler: mux, + }, + } +} + +func (s *Server) Register( + collectors ...prometheus.Collector, +) error { + for _, c := range collectors { + err := s.registry.Register(c) + if err != nil { + return err + } + } + + return nil +} + +func (s *Server) ListenAndServe() error { + slog.Info( + "starting prometheus server", + slog.String("addr", s.Addr), + ) + + return s.Server.ListenAndServe() +} + +func RunServer( + namespace string, + registry *prometheus.Registry, + options ServerOptions, +) error { + if namespace == "" { + namespace = "node" + } + + s := NewServer(registry, options) + + collector := NewCollector(namespace) + err := s.Register(collector) + if err != nil { + return err + } + + return s.ListenAndServe() +}