feat: making things a lot more proper (#11)

This commit is contained in:
Rasmus Wejlgaard 2026-05-20 20:57:06 +01:00 committed by GitHub
parent dd1e39769e
commit 419f462300
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 148 additions and 13 deletions

25
.github/dependabot.yml vendored Normal file
View file

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

View file

@ -16,3 +16,8 @@ jobs:
- name: Test - name: Test
run: go test -race ./... run: go test -race ./...
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
version: latest

View file

@ -66,6 +66,9 @@ jobs:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.version.outputs.version }}
COMMIT=${{ github.sha }}
tags: | tags: |
rwejlgaard/octopus_exporter:${{ steps.version.outputs.version }} rwejlgaard/octopus_exporter:${{ steps.version.outputs.version }}
rwejlgaard/octopus_exporter:latest rwejlgaard/octopus_exporter:latest

3
.gitignore vendored
View file

@ -1,2 +1,5 @@
.env .env
coverage.out coverage.out
/octopus_exporter
*.test
*.out

23
.golangci.yml Normal file
View file

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

View file

@ -1,12 +1,17 @@
FROM golang:1.24-alpine AS builder FROM golang:1.24-alpine AS builder
ARG VERSION=dev
ARG COMMIT=none
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /octopus_exporter /octopus_exporter COPY --from=builder /octopus_exporter /octopus_exporter
USER 65534:65534
EXPOSE 9359 EXPOSE 9359
ENTRYPOINT ["/octopus_exporter"] ENTRYPOINT ["/octopus_exporter"]

View file

@ -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) | | `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 ## Configuration
@ -48,6 +59,7 @@ Metrics are updated every 60 seconds.
| `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number | | `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number |
| `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas smart device ID directly | | `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas 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`) |
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.
@ -79,7 +91,7 @@ services:
## Running from source ## Running from source
Requires Go 1.22+. Requires Go 1.24+.
```sh ```sh
git clone https://github.com/rwejlgaard/octopus_exporter git clone https://github.com/rwejlgaard/octopus_exporter

View file

@ -63,7 +63,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
return nil, err return nil, err
} }
if resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusTooManyRequests {
resp.Body.Close() _ = resp.Body.Close()
if attempt == maxRetries { if attempt == maxRetries {
return nil, errors.New("rate limited: max retries exceeded") return nil, errors.New("rate limited: max retries exceeded")
} }
@ -82,7 +82,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
continue continue
} }
raw, err := io.ReadAll(resp.Body) raw, err := io.ReadAll(resp.Body)
resp.Body.Close() _ = resp.Body.Close()
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -1,12 +1,15 @@
package main package main
import ( import (
"context"
"errors" "errors"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
@ -14,8 +17,10 @@ import (
) )
var ( var (
apiKey string apiKey string
port string port string
version = "dev"
commit = "none"
) )
func mustEnv(key string) string { 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}) 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() { func main() {
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)
log.Printf("octopus_exporter %s (%s), poll interval %s", version, commit, pollInterval)
token, err := getKrakenToken(apiKey) token, err := getKrakenToken(apiKey)
if err != nil { if err != nil {
@ -91,16 +111,24 @@ func main() {
// Exporter health // Exporter health
exporterUp := gauge("octopus_up", "1 if the last poll cycle completed without errors, 0 otherwise") 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") 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") 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") 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{ toRegister := []prometheus.Collector{
elecDemand, elecLastRead, elecDemand, elecLastRead,
elecConsumption, elecConsumptionInterval, elecConsumption, elecConsumptionInterval,
elecUnitRate, elecStandingCharge, elecUnitRate, elecStandingCharge,
accountBalance, accountBalance,
exporterUp, pollErrors, tokenRefreshCount, rateLimitRetries, exporterUp, lastPollTimestamp, pollErrors, tokenRefreshCount, rateLimitRetries,
buildInfo,
} }
var ( var (
@ -123,10 +151,23 @@ func main() {
prometheus.MustRegister(toRegister...) 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() { go func() {
log.Printf("serving metrics on :%s/metrics", port) 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) log.Fatal(err)
} }
}() }()
@ -163,7 +204,10 @@ func main() {
return fn(newT) return fn(newT)
} }
for { ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
poll := func() {
var ( var (
wg sync.WaitGroup wg sync.WaitGroup
failedAny atomic.Bool failedAny atomic.Bool
@ -296,7 +340,22 @@ func main() {
} else { } else {
exporterUp.Set(1) 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()
}
} }
} }

2
go.mod
View file

@ -1,6 +1,6 @@
module github.com/pez/octopus_exporter module github.com/pez/octopus_exporter
go 1.22 go 1.24
require github.com/prometheus/client_golang v1.19.1 require github.com/prometheus/client_golang v1.19.1