diff --git a/README.md b/README.md index a449f33..fb16532 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/octopus_exporter/client.go b/cmd/octopus_exporter/client.go index 143dbbc..dfb330a 100644 --- a/cmd/octopus_exporter/client.go +++ b/cmd/octopus_exporter/client.go @@ -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 diff --git a/cmd/octopus_exporter/consumption.go b/cmd/octopus_exporter/consumption.go index 6ff790a..222afcb 100644 --- a/cmd/octopus_exporter/consumption.go +++ b/cmd/octopus_exporter/consumption.go @@ -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) diff --git a/cmd/octopus_exporter/main.go b/cmd/octopus_exporter/main.go index 6661d5a..b370198 100644 --- a/cmd/octopus_exporter/main.go +++ b/cmd/octopus_exporter/main.go @@ -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: diff --git a/cmd/octopus_exporter/meters.go b/cmd/octopus_exporter/meters.go index 5041ed7..3b7201e 100644 --- a/cmd/octopus_exporter/meters.go +++ b/cmd/octopus_exporter/meters.go @@ -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 } diff --git a/cmd/octopus_exporter/telemetry.go b/cmd/octopus_exporter/telemetry.go index 3ce212f..2bda7ab 100644 --- a/cmd/octopus_exporter/telemetry.go +++ b/cmd/octopus_exporter/telemetry.go @@ -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])