carbon-exporter/main.go
2026-02-17 09:55:08 +00:00

195 lines
4.3 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const (
envPostcode = "CARBON_POSTCODE"
)
type apiResponse struct {
Data []headerData `json:"data"`
}
type headerData struct {
RegionID int `json:"regionid"`
DNORegion string `json:"dnoregion"`
Shortname string `json:"shortname"`
Postcode string `json:"postcode"`
Data []carbonData `json:"data"`
}
type carbonData struct {
From string `json:"from"`
To string `json:"to"`
Intensity intensity `json:"intensity"`
GenerationMix []generationMix `json:"generationmix"`
}
type intensity struct {
Forecast float64 `json:"forecast"`
Index string `json:"index"`
}
type generationMix struct {
Fuel string `json:"fuel"`
Perc float64 `json:"perc"`
}
type metrics struct {
genPerc *prometheus.GaugeVec
intensityFC *prometheus.GaugeVec
}
func (m *metrics) reset() {
m.genPerc.Reset()
m.intensityFC.Reset()
}
func collect(ctx context.Context, postcode string, metrics *metrics) error {
data, err := getCarbonData(ctx, postcode)
if err != nil {
return err
}
if len(data.Data) == 0 {
return fmt.Errorf("no data returned from API")
}
latest := data.Data[0]
if len(latest.Data) == 0 {
return fmt.Errorf("no data for provided postcode")
}
for _, datum := range latest.Data[0].GenerationMix {
labels := prometheus.Labels{"fuel": datum.Fuel, "region": latest.DNORegion}
metrics.genPerc.With(labels).Set(datum.Perc)
}
metrics.intensityFC.With(prometheus.Labels{"region": latest.DNORegion}).Set(latest.Data[0].Intensity.Forecast)
return nil
}
func getCarbonData(ctx context.Context, postcode string) (apiResponse, error) {
carbonURL := fmt.Sprintf("https://api.carbonintensity.org.uk/regional/postcode/%s", postcode)
req, err := http.NewRequestWithContext(ctx, "GET", carbonURL, nil)
if err != nil {
return apiResponse{}, err
}
response, err := http.DefaultClient.Do(req)
if err != nil {
return apiResponse{}, err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return apiResponse{}, fmt.Errorf("carbon API response code was %d", response.StatusCode)
}
var data apiResponse
err = json.NewDecoder(response.Body).Decode(&data)
if err != nil {
return apiResponse{}, err
}
return data, nil
}
func NewMetrics(reg prometheus.Registerer) *metrics {
metrics := &metrics{
genPerc: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "sensor_carbon_generation_perc",
Help: "Generation percent % (float)",
},
[]string{"fuel", "region"},
),
intensityFC: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "sensor_carbon_intensity_forecast",
Help: "Intensity forecast grams (int)",
},
[]string{"region"},
),
}
reg.MustRegister(
metrics.genPerc,
metrics.intensityFC,
)
return metrics
}
func main() {
postcode := os.Getenv(envPostcode)
if postcode == "" {
slog.Error("missing required environment variables", "required", []string{envPostcode})
os.Exit(1)
}
var healthCheck = flag.Bool("health-check", false, "Perform health check and exit")
flag.Parse()
if *healthCheck {
resp, err := http.Get("http://127.0.0.1:8080/health")
if err != nil {
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
os.Exit(1)
}
os.Exit(0)
}
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "OK")
})
registry := prometheus.NewRegistry()
m := NewMetrics(registry)
promHandler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
slog.Info("Metrics queried")
m.reset()
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
err := collect(ctx, postcode, m)
if err != nil {
slog.Warn("error collecting metrics", "error", err)
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, "error collecting metrics")
return
}
promHandler.ServeHTTP(w, r)
})
slog.Info("Listening", "port", 8080)
err := http.ListenAndServe(":8080", nil)
if err != nil {
slog.Error("unable to start web server", "error", err)
}
}