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 |
|
||||
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
||||
| `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.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -76,7 +76,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
|
|||
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)
|
||||
backoff *= 2
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
var errNoConsumptionData = errors.New("no consumption data in last 7 days")
|
||||
|
||||
type consumptionReading struct {
|
||||
KWh float64
|
||||
IntervalStart time.Time
|
||||
|
|
@ -34,7 +36,7 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
|
|||
|
||||
results := toSlice(result["results"])
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
|
@ -23,10 +23,15 @@ var (
|
|||
commit = "none"
|
||||
)
|
||||
|
||||
func fatal(msg string, args ...any) {
|
||||
slog.Error(msg, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func mustEnv(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
log.Fatalf("required environment variable %s is not set", key)
|
||||
fatal("required environment variable not set", "var", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
|
@ -53,51 +58,63 @@ func parseInterval(key string, def time.Duration) time.Duration {
|
|||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
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 d
|
||||
}
|
||||
|
||||
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")
|
||||
port = envOrDefault("PORT", "9359")
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to discover meters: %v", err)
|
||||
fatal("failed to discover meters", "err", err)
|
||||
}
|
||||
|
||||
elecMeter, err := resolveMeter(candidates, electricity)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve electricity meter: %v", err)
|
||||
fatal("failed to resolve electricity meter", "err", err)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to resolve gas meter: %v", err)
|
||||
fatal("failed to resolve gas meter", "err", err)
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
log.Println("no solar export meter found — solar metrics disabled")
|
||||
slog.Info("no solar export meter found — solar metrics disabled")
|
||||
}
|
||||
|
||||
// --- Metrics ---
|
||||
|
|
@ -190,9 +207,9 @@ func main() {
|
|||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
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) {
|
||||
log.Fatal(err)
|
||||
fatal("HTTP server failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -216,7 +233,7 @@ func main() {
|
|||
newT, e := getKrakenToken(apiKey)
|
||||
if e != nil {
|
||||
tokenMu.Unlock()
|
||||
log.Printf("token refresh failed: %v", e)
|
||||
slog.Error("token refresh failed", "err", e)
|
||||
return err
|
||||
}
|
||||
token = newT
|
||||
|
|
@ -237,8 +254,8 @@ func main() {
|
|||
failedAny atomic.Bool
|
||||
)
|
||||
|
||||
fail := func(format string, args ...any) {
|
||||
log.Printf(format, args...)
|
||||
fail := func(msg string, args ...any) {
|
||||
slog.Error(msg, args...)
|
||||
pollErrors.Inc()
|
||||
failedAny.Store(true)
|
||||
}
|
||||
|
|
@ -247,8 +264,15 @@ func main() {
|
|||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fn(); err != nil {
|
||||
fail("%s error: %v", name, err)
|
||||
err := fn()
|
||||
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 != "" {
|
||||
agileRate, err := getCurrentAgileRate(collectedRates.ElectricityProductCode, collectedRates.ElectricityTariffCode, apiKey)
|
||||
if err != nil {
|
||||
fail("agile rate error: %v", err)
|
||||
fail("agile rate error", "err", err)
|
||||
} else {
|
||||
unitRate = agileRate
|
||||
}
|
||||
|
|
@ -415,11 +439,11 @@ func main() {
|
|||
for {
|
||||
select {
|
||||
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)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("HTTP shutdown error: %v", err)
|
||||
slog.Error("HTTP shutdown error", "err", err)
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"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}
|
||||
switch kind {
|
||||
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:
|
||||
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:
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"fmt"
|
||||
)
|
||||
|
||||
var errNoTelemetryData = errors.New("no telemetry data returned")
|
||||
|
||||
type telemetryReading struct {
|
||||
ReadAt string `json:"readAt"`
|
||||
Consumption jsonFloat `json:"consumption"`
|
||||
|
|
@ -29,7 +31,7 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
|||
}
|
||||
telemetry := toSlice(data["smartMeterTelemetry"])
|
||||
if len(telemetry) == 0 {
|
||||
return nil, errors.New("no telemetry data returned")
|
||||
return nil, errNoTelemetryData
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(telemetry[0])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue