mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-07-04 13:56:17 +00:00
Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 85c5a96d1e |
6 changed files with 60 additions and 31 deletions
|
|
@ -75,6 +75,7 @@ Metrics are refreshed every `POLL_INTERVAL` (default 60 seconds).
|
||||||
| `OCTOPUS_SOLAR_DEVICE_ID` | No | Use a specific solar export smart device ID directly |
|
| `OCTOPUS_SOLAR_DEVICE_ID` | No | Use a specific solar export smart device ID directly |
|
||||||
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
||||||
| `POLL_INTERVAL` | No | How often to poll Octopus APIs (Go duration, default: `60s`) |
|
| `POLL_INTERVAL` | No | How often to poll Octopus APIs (Go duration, default: `60s`) |
|
||||||
|
| `LOG_LEVEL` | No | Minimum log level: `debug`, `info`, `warn`, `error` (default: `info`). Logs are emitted as JSON to stderr |
|
||||||
|
|
||||||
If no filter variables are set, the exporter auto-discovers the first smart meter of each type found on the account. Use `OCTOPUS_MPAN` / `OCTOPUS_MPRN` to pin to a specific meter on accounts with multiple meters.
|
If no filter variables are set, the exporter auto-discovers the first smart meter of each type found on the account. Use `OCTOPUS_MPAN` / `OCTOPUS_MPRN` to pin to a specific meter on accounts with multiple meters.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -76,7 +76,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
|
||||||
wait = time.Duration(secs) * time.Second
|
wait = time.Duration(secs) * time.Second
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("rate limited; retrying in %v (attempt %d/%d)", wait, attempt+1, maxRetries)
|
slog.Warn("rate limited; retrying", "wait", wait, "attempt", attempt+1, "max_retries", maxRetries)
|
||||||
time.Sleep(wait)
|
time.Sleep(wait)
|
||||||
backoff *= 2
|
backoff *= 2
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errNoConsumptionData = errors.New("no consumption data in last 7 days")
|
||||||
|
|
||||||
type consumptionReading struct {
|
type consumptionReading struct {
|
||||||
KWh float64
|
KWh float64
|
||||||
IntervalStart time.Time
|
IntervalStart time.Time
|
||||||
|
|
@ -34,7 +36,7 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
|
||||||
|
|
||||||
results := toSlice(result["results"])
|
results := toSlice(result["results"])
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return nil, errors.New("no consumption data in last 7 days")
|
return nil, errNoConsumptionData
|
||||||
}
|
}
|
||||||
|
|
||||||
latest, ok := results[len(results)-1].(map[string]any)
|
latest, ok := results[len(results)-1].(map[string]any)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
|
@ -23,10 +23,15 @@ var (
|
||||||
commit = "none"
|
commit = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func fatal(msg string, args ...any) {
|
||||||
|
slog.Error(msg, args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
func mustEnv(key string) string {
|
func mustEnv(key string) string {
|
||||||
v := os.Getenv(key)
|
v := os.Getenv(key)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
log.Fatalf("required environment variable %s is not set", key)
|
fatal("required environment variable not set", "var", key)
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
@ -53,51 +58,63 @@ func parseInterval(key string, def time.Duration) time.Duration {
|
||||||
}
|
}
|
||||||
d, err := time.ParseDuration(v)
|
d, err := time.ParseDuration(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("invalid %s=%q, falling back to %s: %v", key, v, def, err)
|
slog.Warn("invalid duration, falling back to default", "key", key, "value", v, "default", def, "err", err)
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
logLevel := slog.LevelInfo
|
||||||
|
badLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
if badLevel != "" {
|
||||||
|
if err := logLevel.UnmarshalText([]byte(badLevel)); err == nil {
|
||||||
|
badLevel = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))
|
||||||
|
if badLevel != "" {
|
||||||
|
slog.Warn("invalid LOG_LEVEL, falling back to info", "value", badLevel)
|
||||||
|
}
|
||||||
|
|
||||||
apiKey = mustEnv("OCTOPUS_API_KEY")
|
apiKey = mustEnv("OCTOPUS_API_KEY")
|
||||||
port = envOrDefault("PORT", "9359")
|
port = envOrDefault("PORT", "9359")
|
||||||
pollInterval := parseInterval("POLL_INTERVAL", 60*time.Second)
|
pollInterval := parseInterval("POLL_INTERVAL", 60*time.Second)
|
||||||
log.Printf("octopus_exporter %s (%s), poll interval %s", version, commit, pollInterval)
|
slog.Info("starting octopus_exporter", "version", version, "commit", commit, "poll_interval", pollInterval)
|
||||||
|
|
||||||
token, err := getKrakenToken(apiKey)
|
token, err := getKrakenToken(apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get initial token: %v", err)
|
fatal("failed to get initial token", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("discovering meters from account...")
|
slog.Info("discovering meters from account")
|
||||||
candidates, err := getMeters(token)
|
candidates, err := getMeters(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to discover meters: %v", err)
|
fatal("failed to discover meters", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elecMeter, err := resolveMeter(candidates, electricity)
|
elecMeter, err := resolveMeter(candidates, electricity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to resolve electricity meter: %v", err)
|
fatal("failed to resolve electricity meter", "err", err)
|
||||||
}
|
}
|
||||||
if elecMeter == nil {
|
if elecMeter == nil {
|
||||||
log.Fatal("no electricity smart meter found on account")
|
fatal("no electricity smart meter found on account")
|
||||||
}
|
}
|
||||||
|
|
||||||
gasMeter, err := resolveMeter(candidates, gas)
|
gasMeter, err := resolveMeter(candidates, gas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to resolve gas meter: %v", err)
|
fatal("failed to resolve gas meter", "err", err)
|
||||||
}
|
}
|
||||||
if gasMeter == nil {
|
if gasMeter == nil {
|
||||||
log.Println("no gas smart meter found — gas metrics disabled")
|
slog.Info("no gas smart meter found — gas metrics disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
solarMeter, err := resolveMeter(candidates, solar)
|
solarMeter, err := resolveMeter(candidates, solar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to resolve solar export meter: %v", err)
|
fatal("failed to resolve solar export meter", "err", err)
|
||||||
}
|
}
|
||||||
if solarMeter == nil {
|
if solarMeter == nil {
|
||||||
log.Println("no solar export meter found — solar metrics disabled")
|
slog.Info("no solar export meter found — solar metrics disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Metrics ---
|
// --- Metrics ---
|
||||||
|
|
@ -190,9 +207,9 @@ func main() {
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("serving metrics on :%s/metrics", port)
|
slog.Info("serving metrics", "addr", ":"+port+"/metrics")
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatal(err)
|
fatal("HTTP server failed", "err", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -216,7 +233,7 @@ func main() {
|
||||||
newT, e := getKrakenToken(apiKey)
|
newT, e := getKrakenToken(apiKey)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
tokenMu.Unlock()
|
tokenMu.Unlock()
|
||||||
log.Printf("token refresh failed: %v", e)
|
slog.Error("token refresh failed", "err", e)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
token = newT
|
token = newT
|
||||||
|
|
@ -237,8 +254,8 @@ func main() {
|
||||||
failedAny atomic.Bool
|
failedAny atomic.Bool
|
||||||
)
|
)
|
||||||
|
|
||||||
fail := func(format string, args ...any) {
|
fail := func(msg string, args ...any) {
|
||||||
log.Printf(format, args...)
|
slog.Error(msg, args...)
|
||||||
pollErrors.Inc()
|
pollErrors.Inc()
|
||||||
failedAny.Store(true)
|
failedAny.Store(true)
|
||||||
}
|
}
|
||||||
|
|
@ -247,8 +264,15 @@ func main() {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
if err := fn(); err != nil {
|
err := fn()
|
||||||
fail("%s error: %v", name, err)
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case errors.Is(err, errNoTelemetryData):
|
||||||
|
slog.Warn("no telemetry data", "collector", name)
|
||||||
|
case errors.Is(err, errNoConsumptionData):
|
||||||
|
slog.Info("no consumption data", "collector", name)
|
||||||
|
default:
|
||||||
|
fail(name+" error", "err", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
@ -387,7 +411,7 @@ func main() {
|
||||||
if collectedRates.ElectricityIsAgile && collectedRates.ElectricityProductCode != "" && collectedRates.ElectricityTariffCode != "" {
|
if collectedRates.ElectricityIsAgile && collectedRates.ElectricityProductCode != "" && collectedRates.ElectricityTariffCode != "" {
|
||||||
agileRate, err := getCurrentAgileRate(collectedRates.ElectricityProductCode, collectedRates.ElectricityTariffCode, apiKey)
|
agileRate, err := getCurrentAgileRate(collectedRates.ElectricityProductCode, collectedRates.ElectricityTariffCode, apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail("agile rate error: %v", err)
|
fail("agile rate error", "err", err)
|
||||||
} else {
|
} else {
|
||||||
unitRate = agileRate
|
unitRate = agileRate
|
||||||
}
|
}
|
||||||
|
|
@ -415,11 +439,11 @@ func main() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Println("shutdown signal received, draining HTTP server")
|
slog.Info("shutdown signal received, draining HTTP server")
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
log.Printf("HTTP shutdown error: %v", err)
|
slog.Error("HTTP shutdown error", "err", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -179,11 +179,11 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
|
||||||
m := &resolvedMeter{deviceID: c.deviceID, mpan: c.mpan, mprn: c.mprn, serial: c.serial}
|
m := &resolvedMeter{deviceID: c.deviceID, mpan: c.mpan, mprn: c.mprn, serial: c.serial}
|
||||||
switch kind {
|
switch kind {
|
||||||
case electricity:
|
case electricity:
|
||||||
log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
|
slog.Info("using electricity meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
|
||||||
case gas:
|
case gas:
|
||||||
log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", m.mprn, m.serial, m.deviceID)
|
slog.Info("using gas meter", "mprn", m.mprn, "serial", m.serial, "device_id", m.deviceID)
|
||||||
case solar:
|
case solar:
|
||||||
log.Printf("using solar export meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
|
slog.Info("using solar export meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errNoTelemetryData = errors.New("no telemetry data returned")
|
||||||
|
|
||||||
type telemetryReading struct {
|
type telemetryReading struct {
|
||||||
ReadAt string `json:"readAt"`
|
ReadAt string `json:"readAt"`
|
||||||
Consumption jsonFloat `json:"consumption"`
|
Consumption jsonFloat `json:"consumption"`
|
||||||
|
|
@ -29,7 +31,7 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
||||||
}
|
}
|
||||||
telemetry := toSlice(data["smartMeterTelemetry"])
|
telemetry := toSlice(data["smartMeterTelemetry"])
|
||||||
if len(telemetry) == 0 {
|
if len(telemetry) == 0 {
|
||||||
return nil, errors.New("no telemetry data returned")
|
return nil, errNoTelemetryData
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := json.Marshal(telemetry[0])
|
raw, err := json.Marshal(telemetry[0])
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue