From 419f4623007a2bd7e88452e4cd6a474efbc80fff Mon Sep 17 00:00:00 2001 From: "Rasmus \"Pez\" Wejlgaard" Date: Wed, 20 May 2026 20:57:06 +0100 Subject: [PATCH] feat: making things a lot more proper (#11) --- .github/dependabot.yml | 25 ++++++++++++ .github/workflows/pr-check.yml | 5 +++ .github/workflows/release.yml | 3 ++ .gitignore | 3 ++ .golangci.yml | 23 +++++++++++ Dockerfile | 7 +++- README.md | 16 +++++++- cmd/octopus_exporter/client.go | 4 +- cmd/octopus_exporter/main.go | 73 ++++++++++++++++++++++++++++++---- go.mod | 2 +- 10 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .golangci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d2a5b47 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + go-deps: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: + - "*" + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 8c3ce4c..c4ca786 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -16,3 +16,8 @@ jobs: - name: Test run: go test -race ./... + + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e77f6f5..079a822 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,9 @@ jobs: context: . push: true platforms: linux/amd64,linux/arm64 + build-args: | + VERSION=${{ steps.version.outputs.version }} + COMMIT=${{ github.sha }} tags: | rwejlgaard/octopus_exporter:${{ steps.version.outputs.version }} rwejlgaard/octopus_exporter:latest diff --git a/.gitignore b/.gitignore index fdcd106..efe9bab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .env coverage.out +/octopus_exporter +*.test +*.out diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0e6ad92 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,23 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: standard + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - bodyclose + - gocritic + - unconvert + - unparam + exclusions: + rules: + - path: _test\.go + linters: + - errcheck diff --git a/Dockerfile b/Dockerfile index a608897..9bcbf35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,17 @@ FROM golang:1.24-alpine AS builder +ARG VERSION=dev +ARG COMMIT=none WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 go build -o /octopus_exporter ./cmd/octopus_exporter +RUN CGO_ENABLED=0 go build \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /octopus_exporter ./cmd/octopus_exporter FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /octopus_exporter /octopus_exporter +USER 65534:65534 EXPOSE 9359 ENTRYPOINT ["/octopus_exporter"] diff --git a/README.md b/README.md index 4cea376..8ea449c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,18 @@ Gas metrics are only exposed if a smart gas meter is found on the account. |---|---|---| | `octopus_account_balance_pence` | GraphQL | Account balance in pence (positive = credit, negative = debit) | -Metrics are updated every 60 seconds. +### Exporter health + +| Metric | Description | +|---|---| +| `octopus_up` | 1 if the last poll cycle completed without errors, 0 otherwise | +| `octopus_last_poll_timestamp` | Unix timestamp of the last completed poll cycle | +| `octopus_poll_errors_total` | Total number of collector errors across all poll cycles | +| `octopus_token_refreshes_total` | Total number of successful JWT token refreshes | +| `octopus_rate_limit_retries_total` | Total number of 429 rate-limit retries | +| `octopus_build_info{version,commit}` | Build metadata (always 1) | + +Metrics are refreshed every `POLL_INTERVAL` (default 60 seconds). ## Configuration @@ -48,6 +59,7 @@ Metrics are updated every 60 seconds. | `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number | | `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas 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`) | 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. @@ -79,7 +91,7 @@ services: ## Running from source -Requires Go 1.22+. +Requires Go 1.24+. ```sh git clone https://github.com/rwejlgaard/octopus_exporter diff --git a/cmd/octopus_exporter/client.go b/cmd/octopus_exporter/client.go index e712a4d..143dbbc 100644 --- a/cmd/octopus_exporter/client.go +++ b/cmd/octopus_exporter/client.go @@ -63,7 +63,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) { return nil, err } if resp.StatusCode == http.StatusTooManyRequests { - resp.Body.Close() + _ = resp.Body.Close() if attempt == maxRetries { return nil, errors.New("rate limited: max retries exceeded") } @@ -82,7 +82,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) { continue } raw, err := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if err != nil { return nil, err } diff --git a/cmd/octopus_exporter/main.go b/cmd/octopus_exporter/main.go index f773216..9bcdc81 100644 --- a/cmd/octopus_exporter/main.go +++ b/cmd/octopus_exporter/main.go @@ -1,12 +1,15 @@ package main import ( + "context" "errors" "log" "net/http" "os" + "os/signal" "sync" "sync/atomic" + "syscall" "time" "github.com/prometheus/client_golang/prometheus" @@ -14,8 +17,10 @@ import ( ) var ( - apiKey string - port string + apiKey string + port string + version = "dev" + commit = "none" ) func mustEnv(key string) string { @@ -41,9 +46,24 @@ func counter(name, help string) prometheus.Counter { return prometheus.NewCounter(prometheus.CounterOpts{Name: name, Help: help}) } +func parseInterval(key string, def time.Duration) time.Duration { + v := os.Getenv(key) + if v == "" { + return def + } + d, err := time.ParseDuration(v) + if err != nil { + log.Printf("invalid %s=%q, falling back to %s: %v", key, v, def, err) + return def + } + return d +} + func main() { 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) token, err := getKrakenToken(apiKey) if err != nil { @@ -91,16 +111,24 @@ func main() { // Exporter health exporterUp := gauge("octopus_up", "1 if the last poll cycle completed without errors, 0 otherwise") + lastPollTimestamp := gauge("octopus_last_poll_timestamp", "Unix timestamp of the last completed poll cycle") pollErrors := counter("octopus_poll_errors_total", "Total number of collector errors per poll cycle") tokenRefreshCount := counter("octopus_token_refreshes_total", "Total number of successful JWT token refreshes") rateLimitRetries = counter("octopus_rate_limit_retries_total", "Total number of 429 rate-limit retries across all requests") + buildInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "octopus_build_info", + Help: "Build metadata exposed as a constant gauge (always 1).", + }, []string{"version", "commit"}) + buildInfo.WithLabelValues(version, commit).Set(1) + toRegister := []prometheus.Collector{ elecDemand, elecLastRead, elecConsumption, elecConsumptionInterval, elecUnitRate, elecStandingCharge, accountBalance, - exporterUp, pollErrors, tokenRefreshCount, rateLimitRetries, + exporterUp, lastPollTimestamp, pollErrors, tokenRefreshCount, rateLimitRetries, + buildInfo, } var ( @@ -123,10 +151,23 @@ func main() { prometheus.MustRegister(toRegister...) - http.Handle("/metrics", promhttp.Handler()) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + srv := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } go func() { log.Printf("serving metrics on :%s/metrics", port) - if err := http.ListenAndServe(":"+port, nil); err != nil { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } }() @@ -163,7 +204,10 @@ func main() { return fn(newT) } - for { + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + poll := func() { var ( wg sync.WaitGroup failedAny atomic.Bool @@ -296,7 +340,22 @@ func main() { } else { exporterUp.Set(1) } + lastPollTimestamp.Set(float64(time.Now().Unix())) + } - time.Sleep(60 * time.Second) + poll() + for { + select { + case <-ctx.Done(): + 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 { + log.Printf("HTTP shutdown error: %v", err) + } + return + case <-ticker.C: + poll() + } } } diff --git a/go.mod b/go.mod index 4c608ba..27f22ad 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/pez/octopus_exporter -go 1.22 +go 1.24 require github.com/prometheus/client_golang v1.19.1