Compare commits

..

No commits in common. "main" and "v1.3.0" have entirely different histories.
main ... v1.3.0

6 changed files with 31 additions and 60 deletions

View file

@ -75,7 +75,6 @@ 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.

View file

@ -6,7 +6,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"log"
"net/http"
"strconv"
"strings"
@ -76,7 +76,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
wait = time.Duration(secs) * time.Second
}
}
slog.Warn("rate limited; retrying", "wait", wait, "attempt", attempt+1, "max_retries", maxRetries)
log.Printf("rate limited; retrying in %v (attempt %d/%d)", wait, attempt+1, maxRetries)
time.Sleep(wait)
backoff *= 2
continue

View file

@ -7,8 +7,6 @@ import (
"time"
)
var errNoConsumptionData = errors.New("no consumption data in last 7 days")
type consumptionReading struct {
KWh float64
IntervalStart time.Time
@ -36,7 +34,7 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
results := toSlice(result["results"])
if len(results) == 0 {
return nil, errNoConsumptionData
return nil, errors.New("no consumption data in last 7 days")
}
latest, ok := results[len(results)-1].(map[string]any)

View file

@ -3,7 +3,7 @@ package main
import (
"context"
"errors"
"log/slog"
"log"
"net/http"
"os"
"os/signal"
@ -23,15 +23,10 @@ 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 == "" {
fatal("required environment variable not set", "var", key)
log.Fatalf("required environment variable %s is not set", key)
}
return v
}
@ -58,63 +53,51 @@ func parseInterval(key string, def time.Duration) time.Duration {
}
d, err := time.ParseDuration(v)
if err != nil {
slog.Warn("invalid duration, falling back to default", "key", key, "value", v, "default", def, "err", err)
log.Printf("invalid %s=%q, falling back to %s: %v", key, v, def, 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)
slog.Info("starting octopus_exporter", "version", version, "commit", commit, "poll_interval", pollInterval)
log.Printf("octopus_exporter %s (%s), poll interval %s", version, commit, pollInterval)
token, err := getKrakenToken(apiKey)
if err != nil {
fatal("failed to get initial token", "err", err)
log.Fatalf("failed to get initial token: %v", err)
}
slog.Info("discovering meters from account")
log.Println("discovering meters from account...")
candidates, err := getMeters(token)
if err != nil {
fatal("failed to discover meters", "err", err)
log.Fatalf("failed to discover meters: %v", err)
}
elecMeter, err := resolveMeter(candidates, electricity)
if err != nil {
fatal("failed to resolve electricity meter", "err", err)
log.Fatalf("failed to resolve electricity meter: %v", err)
}
if elecMeter == nil {
fatal("no electricity smart meter found on account")
log.Fatal("no electricity smart meter found on account")
}
gasMeter, err := resolveMeter(candidates, gas)
if err != nil {
fatal("failed to resolve gas meter", "err", err)
log.Fatalf("failed to resolve gas meter: %v", err)
}
if gasMeter == nil {
slog.Info("no gas smart meter found — gas metrics disabled")
log.Println("no gas smart meter found — gas metrics disabled")
}
solarMeter, err := resolveMeter(candidates, solar)
if err != nil {
fatal("failed to resolve solar export meter", "err", err)
log.Fatalf("failed to resolve solar export meter: %v", err)
}
if solarMeter == nil {
slog.Info("no solar export meter found — solar metrics disabled")
log.Println("no solar export meter found — solar metrics disabled")
}
// --- Metrics ---
@ -207,9 +190,9 @@ func main() {
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
slog.Info("serving metrics", "addr", ":"+port+"/metrics")
log.Printf("serving metrics on :%s/metrics", port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fatal("HTTP server failed", "err", err)
log.Fatal(err)
}
}()
@ -233,7 +216,7 @@ func main() {
newT, e := getKrakenToken(apiKey)
if e != nil {
tokenMu.Unlock()
slog.Error("token refresh failed", "err", e)
log.Printf("token refresh failed: %v", e)
return err
}
token = newT
@ -254,8 +237,8 @@ func main() {
failedAny atomic.Bool
)
fail := func(msg string, args ...any) {
slog.Error(msg, args...)
fail := func(format string, args ...any) {
log.Printf(format, args...)
pollErrors.Inc()
failedAny.Store(true)
}
@ -264,15 +247,8 @@ func main() {
wg.Add(1)
go func() {
defer wg.Done()
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)
if err := fn(); err != nil {
fail("%s error: %v", name, err)
}
}()
}
@ -411,7 +387,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", "err", err)
fail("agile rate error: %v", err)
} else {
unitRate = agileRate
}
@ -439,11 +415,11 @@ func main() {
for {
select {
case <-ctx.Done():
slog.Info("shutdown signal received, draining HTTP server")
log.Println("shutdown signal received, draining HTTP server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("HTTP shutdown error", "err", err)
log.Printf("HTTP shutdown error: %v", err)
}
return
case <-ticker.C:

View file

@ -2,7 +2,7 @@ package main
import (
"fmt"
"log/slog"
"log"
"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:
slog.Info("using electricity meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
case gas:
slog.Info("using gas meter", "mprn", m.mprn, "serial", m.serial, "device_id", m.deviceID)
log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", m.mprn, m.serial, m.deviceID)
case solar:
slog.Info("using solar export meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
log.Printf("using solar export meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
}
return m, nil
}

View file

@ -6,8 +6,6 @@ import (
"fmt"
)
var errNoTelemetryData = errors.New("no telemetry data returned")
type telemetryReading struct {
ReadAt string `json:"readAt"`
Consumption jsonFloat `json:"consumption"`
@ -31,7 +29,7 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
}
telemetry := toSlice(data["smartMeterTelemetry"])
if len(telemetry) == 0 {
return nil, errNoTelemetryData
return nil, errors.New("no telemetry data returned")
}
raw, err := json.Marshal(telemetry[0])