From fcc21cfb75290fc6e1af9c9b9eb48a53071ff806 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sun, 20 Nov 2022 14:44:12 +0100 Subject: [PATCH] Add InfluxDB exporter Signed-off-by: Knut Ahlers --- README.md | 1 + config.go | 1 + fetcher.go | 9 +- go.mod | 1 + go.sum | 2 + internal/exporters/influxdb/metrics.go | 112 ++++++++++++++++++ internal/exporters/influxdb/setter.go | 87 ++++++++++++++ internal/exporters/interface.go | 40 +++++++ .../{ => exporters}/prometheus/metrics.go | 0 internal/{ => exporters}/prometheus/setter.go | 18 ++- internal/mercedes/client.go | 9 ++ main.go | 21 ++++ 12 files changed, 292 insertions(+), 9 deletions(-) create mode 100644 internal/exporters/influxdb/metrics.go create mode 100644 internal/exporters/influxdb/setter.go create mode 100644 internal/exporters/interface.go rename internal/{ => exporters}/prometheus/metrics.go (100%) rename internal/{ => exporters}/prometheus/setter.go (85%) diff --git a/README.md b/README.md index 46319fa..9345e82 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Usage of mercedes-byocar-exporter: --client-secret string Client-Secret of Mercedes Developers Console App --credential-file string Where to store tokens when using client-id from CLI parameters (default "credentials.json") --fetch-interval duration How often to ask the Mercedes API for updates (default 15m0s) + --influx-export string Set to url (http[s]://user:pass@host[:port]/database) to enable Influx exporter --listen string Port/IP to listen on (default ":3000") --log-level string Log level (debug, info, warn, error, fatal) (default "info") --redirect-url string Redirect URL registered in Mercedes Developers Console (default "http://127.0.0.1:3000/store-token") diff --git a/config.go b/config.go index 00591a7..806f298 100644 --- a/config.go +++ b/config.go @@ -11,6 +11,7 @@ type ( ClientSecret string `flag:"client-secret" default:"" description:"Client-Secret of Mercedes Developers Console App"` CredentialFile string `flag:"credential-file" default:"credentials.json" description:"Where to store tokens when using client-id from CLI parameters"` FetchInterval time.Duration `flag:"fetch-interval" default:"15m" description:"How often to ask the Mercedes API for updates"` + InfluxExport string `flag:"influx-export" default:"" description:"Set to url (http[s]://user:pass@host[:port]/database) to enable Influx exporter"` Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"` LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"` RedirectURL string `flag:"redirect-url" default:"http://127.0.0.1:3000/store-token" description:"Redirect URL registered in Mercedes Developers Console"` diff --git a/fetcher.go b/fetcher.go index c9ff7de..83c8cf6 100644 --- a/fetcher.go +++ b/fetcher.go @@ -4,7 +4,6 @@ import ( "github.com/sirupsen/logrus" "github.com/Luzifer/mercedes-byocar-exporter/internal/mercedes" - "github.com/Luzifer/mercedes-byocar-exporter/internal/prometheus" ) func getCronFunc(mc mercedes.Client) func() { @@ -24,28 +23,28 @@ func runFetcher(mc mercedes.Client, vehicleID string) { logger.WithError(err).Error("fetching pay-as-you-go data") return } - prometheus.SetPayAsYouGo(vehicleID, s1) + enabledExporters.SetPayAsYouGo(vehicleID, s1) s2, err := mc.GetFuelStatus(cfg.VehicleID[0]) if err != nil { logger.WithError(err).Error("fetching fuel-status data") return } - prometheus.SetFuelStatus(vehicleID, s2) + enabledExporters.SetFuelStatus(vehicleID, s2) s3, err := mc.GetVehicleStatus(cfg.VehicleID[0]) if err != nil { logger.WithError(err).Error("fetching vehicle-status data") return } - prometheus.SetVehicleStatus(vehicleID, s3) + enabledExporters.SetVehicleStatus(vehicleID, s3) s4, err := mc.GetLockStatus(cfg.VehicleID[0]) if err != nil { logger.WithError(err).Error("fetching lock-status data") return } - prometheus.SetLockStatus(vehicleID, s4) + enabledExporters.SetLockStatus(vehicleID, s4) logger.Info("data updated successfully") } diff --git a/go.mod b/go.mod index 8ff27fe..1fec3f7 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Luzifer/rconfig/v2 v2.4.0 github.com/gofrs/uuid v4.3.1+incompatible github.com/hashicorp/vault/api v1.8.2 + github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c github.com/mitchellh/go-homedir v1.1.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.14.0 diff --git a/go.sum b/go.sum index b462401..b102d11 100644 --- a/go.sum +++ b/go.sum @@ -204,6 +204,8 @@ github.com/hashicorp/vault/sdk v0.6.0/go.mod h1:+DRpzoXIdMvKc88R4qxr+edwy/RvH5QK github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb h1:b5rjCoWHc7eqmAS4/qyk21ZsHyb6Mxv/jykxvNTkU4M= github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs= +github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= diff --git a/internal/exporters/influxdb/metrics.go b/internal/exporters/influxdb/metrics.go new file mode 100644 index 0000000..68e6f8f --- /dev/null +++ b/internal/exporters/influxdb/metrics.go @@ -0,0 +1,112 @@ +package influxdb + +import ( + "net/url" + "strings" + "sync" + "time" + + influx "github.com/influxdata/influxdb1-client/v2" + "github.com/pkg/errors" + + "github.com/Luzifer/mercedes-byocar-exporter/internal/exporters" +) + +const ( + influxTimeout = 2 * time.Second + influxWriteInterval = 10 * time.Second +) + +type ( + Exporter struct { + batch influx.BatchPoints + batchLock sync.Mutex + client influx.Client + database string + errs chan error + } +) + +var _ exporters.Exporter = (*Exporter)(nil) + +func New(connURL string) (*Exporter, error) { + out := &Exporter{ + errs: make(chan error, 10), //nolint: gomnd // Is a constant but makes no sense to name + } + return out, out.initialize(connURL) +} + +func (e *Exporter) Errors() <-chan error { + return e.errs +} + +func (e *Exporter) RecordPoint(name string, tags map[string]string, fields map[string]interface{}, updatedAt time.Time) error { + pt, err := influx.NewPoint(name, tags, fields, updatedAt) + if err != nil { + return err + } + + e.batchLock.Lock() + defer e.batchLock.Unlock() + e.batch.AddPoint(pt) + + return nil +} + +func (e *Exporter) resetBatch() error { + b, err := influx.NewBatchPoints(influx.BatchPointsConfig{ + Database: e.database, + }) + if err != nil { + return err + } + + e.batch = b + return nil +} + +func (e *Exporter) sendLoop() { + for range time.Tick(influxWriteInterval) { + + e.batchLock.Lock() + if err := e.client.Write(e.batch); err != nil { + e.errs <- err + e.batchLock.Unlock() + continue + } + e.resetBatch() + e.batchLock.Unlock() + + } +} + +func (e *Exporter) initialize(connURL string) error { + connInfo, err := url.Parse(connURL) + if err != nil { + return errors.Wrap(err, "parsing connection URL") + } + e.database = strings.TrimLeft(connInfo.Path, "/") + + cfg := influx.HTTPConfig{ + Addr: (&url.URL{Scheme: connInfo.Scheme, Host: connInfo.Host}).String(), + Timeout: influxTimeout, + } + + if connInfo.User != nil { + cfg.Username = connInfo.User.Username() + cfg.Password = func(pass string, _ bool) string { return pass }(connInfo.User.Password()) + } + + influxClient, err := influx.NewHTTPClient(cfg) + if err != nil { + return err + } + + e.client = influxClient + if err := e.resetBatch(); err != nil { + return err + } + go e.sendLoop() + + return nil +} diff --git a/internal/exporters/influxdb/setter.go b/internal/exporters/influxdb/setter.go new file mode 100644 index 0000000..5dacfac --- /dev/null +++ b/internal/exporters/influxdb/setter.go @@ -0,0 +1,87 @@ +package influxdb + +import ( + "strings" + + "github.com/Luzifer/mercedes-byocar-exporter/internal/mercedes" +) + +const ( + labelVehicleID = "vehicle_id" + labelDoor = "door" + labelLight = "light" + labelWindow = "window" + + subsystemFuelStatus = "fuel_status" + subsystemLockStatus = "lock_status" + subsystemPayAsYouDrive = "pay_as_you_drive" + subsystemVehicleStatus = "vehicle_status" +) + +func (e *Exporter) SetFuelStatus(vehicleID string, fs mercedes.FuelStatus) { + e.submitValue(fs.RangeLiquid, mn(subsystemFuelStatus, "range_liquid"), labelVehicleID, vehicleID) + e.submitValue(fs.TanklevelPercent, mn(subsystemFuelStatus, "tanklevel_percent"), labelVehicleID, vehicleID) +} + +func (e *Exporter) SetLockStatus(vehicleID string, ls mercedes.LockStatus) { + e.submitValue(ls.DeckLidUnlocked, mn(subsystemLockStatus, "deck_lid_unlocked"), labelVehicleID, vehicleID) + e.submitValue(ls.VehicleStatus, mn(subsystemLockStatus, "vehicle_status"), labelVehicleID, vehicleID) + e.submitValue(ls.GasLidUnlocked, mn(subsystemLockStatus, "gas_lid_unlocked"), labelVehicleID, vehicleID) + e.submitValue(ls.Heading, mn(subsystemLockStatus, "heading"), labelVehicleID, vehicleID) +} + +func (e *Exporter) SetPayAsYouGo(vehicleID string, p mercedes.PayAsYouDriveInsurance) { + e.submitValue(p.Odometer, mn(subsystemPayAsYouDrive, "odometer"), labelVehicleID, vehicleID) +} + +func (e *Exporter) SetVehicleStatus(vehicleID string, vs mercedes.VehicleStatus) { + e.submitValue(vs.DeckLidOpen, mn(subsystemVehicleStatus, "deck_lid_open"), labelVehicleID, vehicleID) + + e.submitValue(vs.DoorFrontLeftOpen, mn(subsystemVehicleStatus, "door_open"), labelVehicleID, vehicleID, labelDoor, "front_left") + e.submitValue(vs.DoorFrontRightOpen, mn(subsystemVehicleStatus, "door_open"), labelVehicleID, vehicleID, labelDoor, "front_right") + e.submitValue(vs.DoorRearLeftOpen, mn(subsystemVehicleStatus, "door_open"), labelVehicleID, vehicleID, labelDoor, "rear_left") + e.submitValue(vs.DoorRearRightOpen, mn(subsystemVehicleStatus, "door_open"), labelVehicleID, vehicleID, labelDoor, "rear_right") + + e.submitValue(vs.InteriorLightsFrontOn, mn(subsystemVehicleStatus, "interior_light_on"), labelVehicleID, vehicleID, labelLight, "front") + e.submitValue(vs.InteriorLightsRearOn, mn(subsystemVehicleStatus, "interior_light_on"), labelVehicleID, vehicleID, labelLight, "rear") + + e.submitValue(vs.LightSwitchPosition, mn(subsystemVehicleStatus, "light_switch_position"), labelVehicleID, vehicleID) + + e.submitValue(vs.ReadingLampFrontLeftOn, mn(subsystemVehicleStatus, "reading_lamp_on"), labelVehicleID, vehicleID, labelLight, "front_left") + e.submitValue(vs.ReadingLampFrontRightOn, mn(subsystemVehicleStatus, "reading_lamp_on"), labelVehicleID, vehicleID, labelLight, "front_right") + + e.submitValue(vs.RoofTopStatus, mn(subsystemVehicleStatus, "roof_top_status"), labelVehicleID, vehicleID) + e.submitValue(vs.SunRoofStatus, mn(subsystemVehicleStatus, "sun_roof_status"), labelVehicleID, vehicleID) + + e.submitValue(vs.WindowStatusFrontLeft, mn(subsystemVehicleStatus, "window_status"), labelVehicleID, vehicleID, labelWindow, "front_left") + e.submitValue(vs.WindowStatusFrontRight, mn(subsystemVehicleStatus, "window_status"), labelVehicleID, vehicleID, labelWindow, "front_right") + e.submitValue(vs.WindowStatusRearLeft, mn(subsystemVehicleStatus, "window_status"), labelVehicleID, vehicleID, labelWindow, "rear_left") + e.submitValue(vs.WindowStatusRearRight, mn(subsystemVehicleStatus, "window_status"), labelVehicleID, vehicleID, labelWindow, "rear_right") +} + +func (e *Exporter) submitValue(value mercedes.MetricValue, metric_name string, tvs ...string) { + if !value.IsValid() { + return + } + + v := map[string]any{"value": value.ToFloat()} + e.RecordPoint(metric_name, tags(tvs...), v, value.Time()) +} + +func mn(parts ...string) string { + return strings.Join(parts, "_") +} + +func tags(kvs ...string) map[string]string { + out := make(map[string]string) + + if len(kvs)%2 != 0 { + panic("invalid tags given") + } + + for i := 0; i < len(kvs); i += 2 { + out[kvs[i]] = kvs[i+1] + } + + return out +} diff --git a/internal/exporters/interface.go b/internal/exporters/interface.go new file mode 100644 index 0000000..481e6be --- /dev/null +++ b/internal/exporters/interface.go @@ -0,0 +1,40 @@ +package exporters + +import "github.com/Luzifer/mercedes-byocar-exporter/internal/mercedes" + +type ( + Exporter interface { + SetFuelStatus(vehicleID string, fs mercedes.FuelStatus) + SetLockStatus(vehicleID string, ls mercedes.LockStatus) + SetPayAsYouGo(vehicleID string, p mercedes.PayAsYouDriveInsurance) + SetVehicleStatus(vehicleID string, vs mercedes.VehicleStatus) + } + + Set []Exporter +) + +var _ Exporter = Set{} + +func (s Set) SetFuelStatus(vehicleID string, fs mercedes.FuelStatus) { + for _, e := range s { + e.SetFuelStatus(vehicleID, fs) + } +} + +func (s Set) SetLockStatus(vehicleID string, ls mercedes.LockStatus) { + for _, e := range s { + e.SetLockStatus(vehicleID, ls) + } +} + +func (s Set) SetPayAsYouGo(vehicleID string, p mercedes.PayAsYouDriveInsurance) { + for _, e := range s { + e.SetPayAsYouGo(vehicleID, p) + } +} + +func (s Set) SetVehicleStatus(vehicleID string, vs mercedes.VehicleStatus) { + for _, e := range s { + e.SetVehicleStatus(vehicleID, vs) + } +} diff --git a/internal/prometheus/metrics.go b/internal/exporters/prometheus/metrics.go similarity index 100% rename from internal/prometheus/metrics.go rename to internal/exporters/prometheus/metrics.go diff --git a/internal/prometheus/setter.go b/internal/exporters/prometheus/setter.go similarity index 85% rename from internal/prometheus/setter.go rename to internal/exporters/prometheus/setter.go index e25226b..6b9d1bd 100644 --- a/internal/prometheus/setter.go +++ b/internal/exporters/prometheus/setter.go @@ -3,26 +3,36 @@ package prometheus import ( "github.com/prometheus/client_golang/prometheus" + "github.com/Luzifer/mercedes-byocar-exporter/internal/exporters" "github.com/Luzifer/mercedes-byocar-exporter/internal/mercedes" ) -func SetFuelStatus(vehicleID string, fs mercedes.FuelStatus) { +type ( + exporter struct{} +) + +var ( + Exporter exporter + _ exporters.Exporter = exporter{} +) + +func (exporter) SetFuelStatus(vehicleID string, fs mercedes.FuelStatus) { setGaugeVecValue(fs.RangeLiquid, fuelRangeLiquidVec, labelVehicleID, vehicleID) setGaugeVecValue(fs.TanklevelPercent, fuelTanklevelPercent, labelVehicleID, vehicleID) } -func SetLockStatus(vehicleID string, ls mercedes.LockStatus) { +func (exporter) SetLockStatus(vehicleID string, ls mercedes.LockStatus) { setGaugeVecValue(ls.DeckLidUnlocked, lockDeckLidUnlocked, labelVehicleID, vehicleID) setGaugeVecValue(ls.VehicleStatus, lockVehicleStatus, labelVehicleID, vehicleID) setGaugeVecValue(ls.GasLidUnlocked, lockGasLidUnlocked, labelVehicleID, vehicleID) setGaugeVecValue(ls.Heading, lockHeading, labelVehicleID, vehicleID) } -func SetPayAsYouGo(vehicleID string, p mercedes.PayAsYouDriveInsurance) { +func (exporter) SetPayAsYouGo(vehicleID string, p mercedes.PayAsYouDriveInsurance) { setGaugeVecValue(p.Odometer, paydOdometer, labelVehicleID, vehicleID) } -func SetVehicleStatus(vehicleID string, vs mercedes.VehicleStatus) { +func (exporter) SetVehicleStatus(vehicleID string, vs mercedes.VehicleStatus) { setGaugeVecValue(vs.DeckLidOpen, vehicleDeckLidOpen, labelVehicleID, vehicleID) setGaugeVecValue(vs.DoorFrontLeftOpen, vehicleDoorOpen, labelVehicleID, vehicleID, labelDoor, "front_left") diff --git a/internal/mercedes/client.go b/internal/mercedes/client.go index 16afdf5..a0dc494 100644 --- a/internal/mercedes/client.go +++ b/internal/mercedes/client.go @@ -18,6 +18,7 @@ type ( MetricValue interface { IsValid() bool + Time() time.Time ToFloat() float64 } @@ -89,6 +90,8 @@ func (t TimedBool) IsValid() bool { return !t.t.IsZero() } func (t TimedBool) String() string { return fmt.Sprintf("%v (%s)", t.v, t.t.Format(time.RFC3339)) } +func (t TimedBool) Time() time.Time { return t.t } + func (t TimedBool) ToFloat() float64 { if t.v { return 1 @@ -110,6 +113,8 @@ func (t TimedEnum) String() string { return fmt.Sprintf("%s (%s)", s, t.t.Format(time.RFC3339)) } +func (t TimedEnum) Time() time.Time { return t.t } + func (t TimedEnum) ToFloat() float64 { return float64(t.v) } func (t TimedEnum) Value() string { @@ -128,6 +133,8 @@ func (t TimedFloat) IsValid() bool { return !t.t.IsZero() } func (t TimedFloat) String() string { return fmt.Sprintf("%v (%s)", t.v, t.t.Format(time.RFC3339)) } +func (t TimedFloat) Time() time.Time { return t.t } + func (t TimedFloat) ToFloat() float64 { return t.v } // Int @@ -138,4 +145,6 @@ func (t TimedInt) IsValid() bool { return !t.t.IsZero() } func (t TimedInt) String() string { return fmt.Sprintf("%v (%s)", t.v, t.t.Format(time.RFC3339)) } +func (t TimedInt) Time() time.Time { return t.t } + func (t TimedInt) ToFloat() float64 { return float64(t.v) } diff --git a/main.go b/main.go index 14928fc..f1bd0a8 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,9 @@ import ( "github.com/sirupsen/logrus" "github.com/Luzifer/mercedes-byocar-exporter/internal/credential" + "github.com/Luzifer/mercedes-byocar-exporter/internal/exporters" + "github.com/Luzifer/mercedes-byocar-exporter/internal/exporters/influxdb" + "github.com/Luzifer/mercedes-byocar-exporter/internal/exporters/prometheus" "github.com/Luzifer/mercedes-byocar-exporter/internal/mercedes" "github.com/Luzifer/rconfig/v2" ) @@ -19,6 +22,8 @@ import ( var ( cfg cliConfig version = "dev" + + enabledExporters exporters.Set ) func initApp() error { @@ -73,6 +78,22 @@ func main() { } mClient := mercedes.New(clientID, clientSecret, creds) + // Register Exporters + enabledExporters = append(enabledExporters, prometheus.Exporter) + if cfg.InfluxExport != "" { + logrus.Info("creating influxdb exporter") + influxExporter, err := influxdb.New(cfg.InfluxExport) + if err != nil { + logrus.WithError(err).Fatal("creating influxdb exporter") + } + go func() { + for err := range influxExporter.Errors() { + logrus.WithError(err).Error("processing influx metrics") + } + }() + enabledExporters = append(enabledExporters, influxExporter) + } + // Register HTTP handlers http.DefaultServeMux.HandleFunc("/auth", getAuthRedirectHandler(mClient)) http.DefaultServeMux.HandleFunc("/store-token", getAuthStoreTokenHandler(mClient, creds))