package main import ( "log" "net/http" "os" "time" "github.com/PDOK/betterstack-exporter/internal/betterstack" "github.com/PDOK/betterstack-exporter/internal/metrics" "github.com/iancoleman/strcase" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/urfave/cli/v2" ) const ( APITokenFlag = "api-token" BindAddressFlag = "bind-address" PageSizeFlag = "page-size" ) var ( cliFlags = []cli.Flag{ &cli.StringFlag{ Name: APITokenFlag, Usage: "The API token to authenticate with Better Stack.", EnvVars: []string{strcase.ToScreamingSnake(APITokenFlag)}, Required: true, }, &cli.StringFlag{ Name: BindAddressFlag, Usage: "The TCP network address addr that is listened on.", Value: ":8080", EnvVars: []string{strcase.ToScreamingSnake(BindAddressFlag)}, }, &cli.IntFlag{ Name: PageSizeFlag, Usage: "The number of monitors to request per page (max 250).", Value: 50, EnvVars: []string{strcase.ToScreamingSnake(PageSizeFlag)}, }, } ) func main() { app := cli.NewApp() app.HelpName = "Better Stack Exporter" app.Name = "betterstack-exporter" app.Usage = "Collects Better Stack uptime statuses and exports as Prometheus metrics" app.Flags = cliFlags app.Action = func(c *cli.Context) error { config := betterstack.Config{ APIToken: c.String(APITokenFlag), PageSize: c.Int(PageSizeFlag), } client := betterstack.NewClient(config) metricsUpdater := metrics.NewUpdater(client) prometheus.MustRegister(metricsUpdater) bindAddress := c.String(BindAddressFlag) http.Handle("/metrics", promhttp.Handler()) server := &http.Server{ Addr: bindAddress, ReadHeaderTimeout: 10 * time.Second, } log.Printf("listening on %s", bindAddress) return server.ListenAndServe() } err := app.Run(os.Args) if err != nil { log.Fatal(err) } }
package betterstack import ( "encoding/json" "fmt" "io" "net/http" "time" ) const ( betterStackBaseURL = "https://uptime.betterstack.com" HeaderAuthorization = "Authorization" HeaderAccept = "Accept" HeaderContentType = "Content-Type" HeaderUserAgent = "User-Agent" MediaTypeJSON = "application/json" ) type Client struct { httpClient *http.Client config Config } type Config struct { APIToken string PageSize int } type Monitor struct { ID string URL string PronounceableName string Status string } // Maps the relevant subset of fields from the 'list monitors' API type MonitorListResponse struct { Data []struct { ID string `json:"id"` Attributes *struct { URL string `json:"url"` PronounceableName string `json:"pronounceable_name"` Status string `json:"status"` } `json:"attributes"` } `json:"data"` Pagination *struct { First string `json:"first"` Last string `json:"last"` Prev string `json:"prev"` Next string `json:"next"` } `json:"pagination"` } func NewClient(config Config) Client { if config.PageSize < 1 { config.PageSize = 50 // default https://betterstack.com/docs/uptime/api/pagination/ } if config.PageSize > 250 { config.PageSize = 250 // maximum https://betterstack.com/docs/uptime/api/pagination/ } return Client{ config: config, httpClient: &http.Client{Timeout: time.Duration(5) * time.Minute}, } } func (c Client) execRequest(req *http.Request, expectedStatus int) (*http.Response, error) { req.Header.Set(HeaderAuthorization, "Bearer "+c.config.APIToken) req.Header.Set(HeaderAccept, MediaTypeJSON) req.Header.Set(HeaderContentType, MediaTypeJSON) req.Header.Add(HeaderUserAgent, "betterstack-exporter") resp, err := c.httpClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != expectedStatus { defer resp.Body.Close() result, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("got status %d, expected %d. Body: %b", resp.StatusCode, expectedStatus, result) } return resp, nil // caller should close resp.Body! } func (c Client) listMonitors() (*MonitorListResponse, error) { // Make HTTP request to the list monitors URL url := fmt.Sprintf("%s/api/v2/monitors?per_page=%d", betterStackBaseURL, c.config.PageSize) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := c.execRequest(req, http.StatusOK) if err != nil { return nil, err } defer resp.Body.Close() // Parse response var monitors MonitorListResponse err = json.NewDecoder(resp.Body).Decode(&monitors) if err != nil { return nil, err } return &monitors, nil } func (c Client) ListMonitors() ([]Monitor, error) { result := []Monitor{} monitors, err := c.listMonitors() if err != nil { return []Monitor{}, err } for { for _, monitor := range monitors.Data { result = append(result, Monitor{ ID: monitor.ID, PronounceableName: monitor.Attributes.PronounceableName, URL: monitor.Attributes.URL, Status: monitor.Attributes.Status, }) } if !monitors.hasNext() { break // exit infinite loop } monitors, err = monitors.next(c) if err != nil { return []Monitor{}, err } } return result, nil } func (m MonitorListResponse) hasNext() bool { return m.Pagination != nil && m.Pagination.Next != "" } func (m MonitorListResponse) next(client Client) (*MonitorListResponse, error) { if !m.hasNext() { return nil, nil } // Make HTTP request to the next URL req, err := http.NewRequest(http.MethodGet, m.Pagination.Next, nil) if err != nil { return nil, err } resp, err := client.execRequest(req, http.StatusOK) if err != nil { return nil, err } defer resp.Body.Close() // Parse response var nextPage MonitorListResponse err = json.NewDecoder(resp.Body).Decode(&nextPage) if err != nil { return nil, err } return &nextPage, nil }
package metrics import ( "log" "github.com/PDOK/betterstack-exporter/internal/betterstack" "github.com/prometheus/client_golang/prometheus" ) var ( statusCodes = map[string]float64{ "down": 0, "maintenance": 1, "up": 2, "paused": 3, "pending": 4, "validating": 5, } ) type Updater struct { client betterstack.Client BetterStackMonitorStatus *prometheus.GaugeVec } func NewUpdater(client betterstack.Client) *Updater { betterStackMonitorStatus := prometheus.NewGaugeVec( prometheus.GaugeOpts{ Namespace: "betterstack", Name: "monitor_status", Help: "The current status of the check (0: down, 1: maintenance, 2: up, 3: paused, 4: pending, 5: validating)", }, []string{ "id", "pronounceable_name", "url", }, ) return &Updater{ client: client, BetterStackMonitorStatus: betterStackMonitorStatus, } } func (u *Updater) Describe(ch chan<- *prometheus.Desc) { u.BetterStackMonitorStatus.Describe(ch) } func (u *Updater) Collect(ch chan<- prometheus.Metric) { u.UpdatePromMetrics() u.BetterStackMonitorStatus.Collect(ch) } func (u *Updater) UpdatePromMetrics() { log.Println("start updating uptime metrics") monitors, err := u.client.ListMonitors() if err != nil { log.Fatal(err) } for _, monitor := range monitors { labels := map[string]string{ "id": monitor.ID, "pronounceable_name": monitor.PronounceableName, "url": monitor.URL, } u.BetterStackMonitorStatus.With(labels).Set(statusCodes[monitor.Status]) } log.Println("finished updating uptime metrics") }