package main import ( "encoding/csv" "flag" "fmt" "io" "log" "math/rand" "net/http" "strconv" "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) type exporter struct { mu sync.RWMutex uptime *prometheus.GaugeVec fetchOk prometheus.Gauge fetchAt prometheus.Gauge } func newExporter(reg prometheus.Registerer) *exporter { e := &exporter{ uptime: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "ct_log_uptime_ratio", Help: "24h uptime ratio (0-1) for a CT log endpoint, sourced from gstatic.com.", }, []string{"log_url", "endpoint"}), fetchOk: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "ct_log_uptime_fetch_success", Help: "1 if the last CSV fetch succeeded, 0 otherwise.", }), fetchAt: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "ct_log_uptime_fetch_timestamp_seconds", Help: "Unix timestamp of the last CSV fetch attempt.", }), } reg.MustRegister(e.uptime, e.fetchOk, e.fetchAt) return e } func (e *exporter) fetch(csvURL string) error { resp, err := http.Get(csvURL) if err != nil { return fmt.Errorf("GET %s: %w", csvURL, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status %s", resp.Status) } r := csv.NewReader(resp.Body) // skip header if _, err := r.Read(); err != nil { return fmt.Errorf("read header: %w", err) } // collect new values before updating metrics type row struct { logURL, endpoint string ratio float64 } var rows []row for { rec, err := r.Read() if err == io.EOF { break } if err != nil { return fmt.Errorf("read row: %w", err) } if len(rec) < 3 { continue } pct, err := strconv.ParseFloat(rec[2], 64) if err != nil { log.Printf("skipping row with unparseable uptime %q: %v", rec[2], err) continue } rows = append(rows, row{rec[0], rec[1], pct / 100.0}) } e.mu.Lock() defer e.mu.Unlock() e.uptime.Reset() for _, row := range rows { e.uptime.WithLabelValues(row.logURL, row.endpoint).Set(row.ratio) } return nil } func (e *exporter) run(csvURL string, interval, jitter time.Duration) { do := func() { e.fetchAt.SetToCurrentTime() if err := e.fetch(csvURL); err != nil { log.Printf("fetch error: %v", err) e.fetchOk.Set(0) } else { log.Printf("fetch ok") e.fetchOk.Set(1) } } do() for { delta := time.Duration(rand.Int63n(int64(2*jitter))) - jitter sleep := interval + delta log.Printf("next fetch in %s", sleep.Round(time.Second)) time.Sleep(sleep) do() } } func main() { addr := flag.String("listen", ":9781", "address to listen on") csvURL := flag.String("url", "https://www.gstatic.com/ct/compliance/endpoint_uptime_24h.csv", "URL of the uptime CSV") interval := flag.Duration("interval", 25*time.Minute, "how often to fetch the CSV") jitter := flag.Duration("jitter", 5*time.Minute, "maximum +/-jitter applied to the fetch interval") flag.Parse() reg := prometheus.NewRegistry() reg.MustRegister(prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{})) reg.MustRegister(prometheus.NewGoCollector()) exp := newExporter(reg) go exp.run(*csvURL, *interval, *jitter) http.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{})) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `metrics`) }) log.Printf("listening on %s", *addr) log.Fatal(http.ListenAndServe(*addr, nil)) }