feat: json logging (#17)
Some checks failed
Release / release (push) Has been cancelled

This commit is contained in:
Rasmus Wejlgaard 2026-05-26 18:57:54 +01:00 committed by GitHub
parent dbcf50eb13
commit 85c5a96d1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 60 additions and 31 deletions

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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
} }

View file

@ -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])