├── .gitignore ├── sonar-project.properties ├── Dockerfile ├── .github └── workflows │ ├── pull_request.yml │ └── push.yml ├── cmd └── pingdom-exporter │ ├── server.go │ └── main.go ├── go.mod ├── Makefile ├── pkg └── pingdom │ ├── outage.go │ ├── outage_test.go │ ├── check.go │ ├── pingdom_test.go │ ├── api_responses_test.go │ ├── pingdom.go │ ├── check_test.go │ └── api_responses.go ├── LICENSE ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | vendor 3 | cover* 4 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=jusbrasil_pingdom-exporter 2 | sonar.go.coverage.reportPaths=coverage.out -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22 AS build 2 | 3 | WORKDIR /app 4 | ADD . . 5 | RUN make build 6 | 7 | FROM alpine:latest 8 | MAINTAINER Daniel Martins 9 | 10 | RUN apk add --no-cache ca-certificates \ 11 | && update-ca-certificates 12 | COPY --from=build /app/bin/pingdom-exporter /pingdom-exporter 13 | ENTRYPOINT ["/pingdom-exporter"] 14 | 15 | USER 65534:65534 16 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Check pull request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Get all git tags 15 | run: git fetch --prune --unshallow --tags 16 | 17 | - uses: actions/setup-go@v3 18 | with: 19 | go-version: '>=1.17.0' 20 | 21 | - name: Run tests 22 | run: make test 23 | 24 | - name: Build Docker image 25 | run: make image 26 | -------------------------------------------------------------------------------- /cmd/pingdom-exporter/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/prometheus/client_golang/prometheus/promhttp" 7 | ) 8 | 9 | // Server is the object that implements the HTTP server for the exporter. 10 | type Server struct { 11 | mux *http.ServeMux 12 | } 13 | 14 | // NewServer returns a new HTTP server for exposing Prometheus metrics. 15 | func NewServer() *Server { 16 | s := &Server{ 17 | mux: http.NewServeMux(), 18 | } 19 | 20 | s.mux.HandleFunc("/healthz", s.healthz) 21 | s.mux.Handle("/metrics", promhttp.Handler()) 22 | 23 | return s 24 | } 25 | 26 | // ServeHTTP handles incoming HTTP requests. 27 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | s.mux.ServeHTTP(w, r) 29 | } 30 | 31 | func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { 32 | w.Write([]byte("OK")) 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jusbrasil/pingdom-exporter 2 | 3 | require ( 4 | github.com/prometheus/client_golang v1.19.0 5 | github.com/stretchr/testify v1.8.1 6 | ) 7 | 8 | require ( 9 | github.com/beorn7/perks v1.0.1 // indirect 10 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/golang/protobuf v1.5.4 // indirect 13 | github.com/kr/pretty v0.3.1 // indirect 14 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | github.com/prometheus/client_model v0.6.1 // indirect 17 | github.com/prometheus/common v0.53.0 // indirect 18 | github.com/prometheus/procfs v0.14.0 // indirect 19 | golang.org/x/sys v0.19.0 // indirect 20 | google.golang.org/protobuf v1.33.0 // indirect 21 | gopkg.in/yaml.v3 v3.0.1 // indirect 22 | ) 23 | 24 | go 1.22 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO=CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go 2 | BIN=pingdom-exporter 3 | IMAGE=jusbrasil/$(BIN) 4 | DOCKER_BIN=docker 5 | 6 | TAG=$(shell git describe --tags) 7 | 8 | .PHONY: build 9 | build: 10 | $(GO) build -a --ldflags "-X main.VERSION=$(TAG) -w -extldflags '-static'" -tags netgo -o bin/$(BIN) ./cmd/$(BIN) 11 | 12 | .PHONY: test 13 | test: 14 | go vet ./... 15 | go test -coverprofile=coverage.out ./... 16 | go tool cover -func=coverage.out 17 | 18 | .PHONY: lint 19 | lint: 20 | go get -u golang.org/x/lint/golint 21 | golint ./... 22 | 23 | # Build the Docker build stage TARGET 24 | .PHONY: image 25 | image: 26 | $(DOCKER_BIN) build -t $(IMAGE):$(TAG) . 27 | 28 | # Push Docker images to the registry 29 | .PHONY: publish 30 | publish: 31 | $(DOCKER_BIN) push $(IMAGE):$(TAG) 32 | $(DOCKER_BIN) tag $(IMAGE):$(TAG) $(IMAGE):latest 33 | $(DOCKER_BIN) push $(IMAGE):latest 34 | -------------------------------------------------------------------------------- /pkg/pingdom/outage.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | ) 8 | 9 | // OutageSummaryService provides an interface to Pingdom outage summary. 10 | type OutageSummaryService struct { 11 | client *Client 12 | } 13 | 14 | // List returns a list of outage summaries from Pingdom. 15 | func (os *OutageSummaryService) List(checkID int, params ...map[string]string) ([]OutageSummaryResponseState, error) { 16 | param := map[string]string{} 17 | if len(params) == 1 { 18 | param = params[0] 19 | } 20 | 21 | req, err := os.client.NewRequest("GET", fmt.Sprintf("/summary.outage/%d", checkID), param) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | resp, err := os.client.client.Do(req) 27 | if err != nil { 28 | return nil, err 29 | } 30 | defer resp.Body.Close() 31 | 32 | if err := validateResponse(resp); err != nil { 33 | return nil, err 34 | } 35 | 36 | bodyBytes, _ := ioutil.ReadAll(resp.Body) 37 | bodyString := string(bodyBytes) 38 | m := &listOutageSummaryJSONResponse{} 39 | err = json.Unmarshal([]byte(bodyString), &m) 40 | 41 | return m.Summary.States, err 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Get all git tags 17 | run: git fetch --prune --unshallow --tags 18 | 19 | - uses: actions/setup-go@v3 20 | with: 21 | go-version: '>=1.17.0' 22 | 23 | - name: Run tests 24 | run: make test 25 | 26 | - uses: sonarsource/sonarqube-scan-action@master 27 | env: 28 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 29 | SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} 30 | 31 | - uses: sonarsource/sonarqube-quality-gate-action@master 32 | timeout-minutes: 5 33 | env: 34 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 35 | 36 | - name: Build image 37 | run: make image 38 | 39 | - name: Sign in to Docker Hub 40 | run: echo -n ${{secrets.DOCKER_HUB_PASSWORD}} | docker login -u ${{secrets.DOCKER_HUB_USERNAME}} --password-stdin 41 | 42 | - name: Push image to registry 43 | run: make publish 44 | 45 | - name: Sign out from Docker Hub 46 | if: always() 47 | run: docker logout 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Goshme Soluções para a Internet LTDA 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /pkg/pingdom/outage_test.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestOutageSummaryServiceList(t *testing.T) { 12 | setup() 13 | defer teardown() 14 | 15 | mux.HandleFunc("/summary.outage/1", func(w http.ResponseWriter, r *http.Request) { 16 | testMethod(t, r, "GET") 17 | fmt.Fprint(w, `{ 18 | "summary": { 19 | "states": [ 20 | { 21 | "status": "up", 22 | "timefrom": 1293143523, 23 | "timeto": 1294180263 24 | }, 25 | { 26 | "status": "down", 27 | "timefrom": 1294180263, 28 | "timeto": 1294180323 29 | } 30 | ] 31 | } 32 | }`) 33 | }) 34 | 35 | want := []OutageSummaryResponseState{ 36 | { 37 | Status: "up", 38 | FromTime: 1293143523, 39 | ToTime: 1294180263, 40 | }, 41 | { 42 | Status: "down", 43 | FromTime: 1294180263, 44 | ToTime: 1294180323, 45 | }, 46 | } 47 | 48 | checks, err := client.OutageSummary.List(1, map[string]string{ 49 | "from": "1293143523", 50 | }) 51 | 52 | assert.NoError(t, err) 53 | assert.Equal(t, want, checks) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/pingdom/check.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "math" 7 | "net/http" 8 | "regexp" 9 | "strconv" 10 | ) 11 | 12 | var ( 13 | reqLimitHeaderKeys = []string{ 14 | "req-limit-short", 15 | "req-limit-long", 16 | } 17 | reqLimitRe = regexp.MustCompile(`Remaining: (\d+) Time until reset: (\d+)`) 18 | ) 19 | 20 | // CheckService provides an interface to Pingdom checks. 21 | type CheckService struct { 22 | client *Client 23 | } 24 | 25 | // List returns a list of checks from Pingdom. 26 | // This returns type CheckResponse rather than Check since the 27 | // Pingdom API does not return a complete representation of a check. 28 | func (cs *CheckService) List(params ...map[string]string) ([]CheckResponse, float64, error) { 29 | param := map[string]string{} 30 | if len(params) == 1 { 31 | param = params[0] 32 | } 33 | req, err := cs.client.NewRequest("GET", "/checks", param) 34 | if err != nil { 35 | return nil, 0, err 36 | } 37 | 38 | resp, err := cs.client.client.Do(req) 39 | if err != nil { 40 | return nil, 0, err 41 | } 42 | defer resp.Body.Close() 43 | 44 | minRequestLimit := minRequestLimitFromHeader(resp.Header) 45 | 46 | if err := validateResponse(resp); err != nil { 47 | return nil, minRequestLimit, err 48 | } 49 | 50 | bodyBytes, _ := ioutil.ReadAll(resp.Body) 51 | bodyString := string(bodyBytes) 52 | m := &listChecksJSONResponse{} 53 | err = json.Unmarshal([]byte(bodyString), &m) 54 | 55 | return m.Checks, minRequestLimit, err 56 | } 57 | 58 | func minRequestLimitFromHeader(header http.Header) float64 { 59 | minRequestLimit := math.MaxFloat64 60 | 61 | for _, key := range reqLimitHeaderKeys { 62 | matches := reqLimitRe.FindStringSubmatch(header.Get(key)) 63 | if len(matches) > 0 { 64 | limit, err := strconv.ParseFloat(matches[1], 64) 65 | if err == nil && limit < minRequestLimit { 66 | minRequestLimit = limit 67 | } 68 | } 69 | } 70 | 71 | return minRequestLimit 72 | } 73 | -------------------------------------------------------------------------------- /pkg/pingdom/pingdom_test.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var ( 16 | mux *http.ServeMux 17 | client *Client 18 | server *httptest.Server 19 | ) 20 | 21 | func setup() { 22 | // test server 23 | mux = http.NewServeMux() 24 | server = httptest.NewServer(mux) 25 | 26 | // test client 27 | client, _ = NewClientWithConfig(ClientConfig{ 28 | Token: "my_api_token", 29 | }) 30 | 31 | url, _ := url.Parse(server.URL) 32 | client.BaseURL = url 33 | } 34 | 35 | func teardown() { 36 | server.Close() 37 | } 38 | 39 | func testMethod(t *testing.T, r *http.Request, want string) { 40 | assert.Equal(t, want, r.Method) 41 | } 42 | 43 | func TestNewClientWithConfig(t *testing.T) { 44 | c, err := NewClientWithConfig(ClientConfig{ 45 | Token: "my_api_token", 46 | }) 47 | assert.NoError(t, err) 48 | assert.Equal(t, http.DefaultClient, c.client) 49 | assert.Equal(t, defaultBaseURL, c.BaseURL.String()) 50 | assert.NotNil(t, c.Checks) 51 | } 52 | 53 | func TestNewRequest(t *testing.T) { 54 | setup() 55 | defer teardown() 56 | 57 | req, err := client.NewRequest("GET", "/checks", nil) 58 | 59 | assert.NoError(t, err) 60 | assert.Equal(t, "GET", req.Method) 61 | assert.Equal(t, client.BaseURL.String()+"/checks", req.URL.String()) 62 | } 63 | 64 | func TestDo(t *testing.T) { 65 | setup() 66 | defer teardown() 67 | 68 | type foo struct { 69 | A string 70 | } 71 | 72 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 73 | if m := "GET"; m != r.Method { 74 | t.Errorf("Request method = %v, want %v", r.Method, m) 75 | } 76 | fmt.Fprint(w, `{"A":"a"}`) 77 | }) 78 | 79 | req, _ := client.NewRequest("GET", "/", nil) 80 | body := new(foo) 81 | want := &foo{"a"} 82 | 83 | _, _ = client.Do(req, body) 84 | assert.Equal(t, want, body) 85 | } 86 | 87 | func TestValidateResponse(t *testing.T) { 88 | valid := &http.Response{ 89 | Request: &http.Request{}, 90 | StatusCode: http.StatusOK, 91 | Body: ioutil.NopCloser(strings.NewReader("OK")), 92 | } 93 | 94 | assert.NoError(t, validateResponse(valid)) 95 | 96 | invalid := &http.Response{ 97 | Request: &http.Request{}, 98 | StatusCode: http.StatusBadRequest, 99 | Body: ioutil.NopCloser(strings.NewReader(`{ 100 | "error" : { 101 | "statuscode": 400, 102 | "statusdesc": "Bad Request", 103 | "errormessage": "This is an error" 104 | } 105 | }`)), 106 | } 107 | 108 | want := &Error{400, "Bad Request", "This is an error"} 109 | assert.Equal(t, want, validateResponse(invalid)) 110 | } 111 | -------------------------------------------------------------------------------- /pkg/pingdom/api_responses_test.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestErrorError(t *testing.T) { 10 | errorResponse := Error{200, "OK", "Message"} 11 | assert.Equal(t, "200 OK: Message", errorResponse.Error()) 12 | } 13 | 14 | func TestCheckResponseTagsString(t *testing.T) { 15 | checkResponse := CheckResponse{ 16 | Tags: []CheckResponseTag{ 17 | { 18 | Name: "apache", 19 | Type: "a", 20 | Count: 2, 21 | }, 22 | { 23 | Name: "server", 24 | Type: "a", 25 | Count: 2, 26 | }, 27 | }, 28 | } 29 | assert.Equal(t, "apache,server", checkResponse.TagsString()) 30 | } 31 | 32 | func TestHasIgnoredTag(t *testing.T) { 33 | testCases := []struct { 34 | tag CheckResponseTag 35 | expected bool 36 | }{ 37 | { 38 | tag: CheckResponseTag{ 39 | Name: "pingdom_exporter_ignored", 40 | Type: "a", 41 | Count: 2, 42 | }, 43 | expected: true, 44 | }, 45 | { 46 | tag: CheckResponseTag{ 47 | Name: "pingdom_exporter_not_ignored", 48 | Type: "a", 49 | Count: 2, 50 | }, 51 | expected: false, 52 | }, 53 | } 54 | 55 | for _, testCase := range testCases { 56 | response := CheckResponse{ 57 | Tags: []CheckResponseTag{testCase.tag}, 58 | } 59 | 60 | actual := response.HasIgnoreTag() 61 | assert.Equal(t, actual, testCase.expected) 62 | } 63 | } 64 | 65 | func TestCheckResponseUptimeSLOFromTags(t *testing.T) { 66 | testCases := []struct { 67 | tag CheckResponseTag 68 | expectedUptimeSLO float64 69 | }{ 70 | { 71 | tag: CheckResponseTag{ 72 | Name: "uptime_slo_99999", 73 | Type: "a", 74 | Count: 2, 75 | }, 76 | expectedUptimeSLO: 99.999, 77 | }, 78 | { 79 | tag: CheckResponseTag{ 80 | Name: "uptime_slo_99995", 81 | Type: "a", 82 | Count: 2, 83 | }, 84 | expectedUptimeSLO: 99.995, 85 | }, 86 | { 87 | tag: CheckResponseTag{ 88 | Name: "uptime_slo_9999", 89 | Type: "a", 90 | Count: 2, 91 | }, 92 | expectedUptimeSLO: 99.99, 93 | }, 94 | { 95 | tag: CheckResponseTag{ 96 | Name: "uptime_slo_9995", 97 | Type: "a", 98 | Count: 2, 99 | }, 100 | expectedUptimeSLO: 99.95, 101 | }, 102 | { 103 | tag: CheckResponseTag{ 104 | Name: "uptime_slo_999", 105 | Type: "a", 106 | Count: 2, 107 | }, 108 | expectedUptimeSLO: 99.9, 109 | }, 110 | { 111 | tag: CheckResponseTag{ 112 | Name: "uptime_slo_995", 113 | Type: "a", 114 | Count: 2, 115 | }, 116 | expectedUptimeSLO: 99.5, 117 | }, 118 | { 119 | tag: CheckResponseTag{ 120 | Name: "uptime_slo_99", 121 | Type: "a", 122 | Count: 2, 123 | }, 124 | expectedUptimeSLO: 99, 125 | }, 126 | { 127 | tag: CheckResponseTag{ 128 | Name: "uptime_slo_95", 129 | Type: "a", 130 | Count: 2, 131 | }, 132 | expectedUptimeSLO: 95, 133 | }, 134 | { 135 | tag: CheckResponseTag{ 136 | Name: "uptime_slo_9", 137 | Type: "a", 138 | Count: 2, 139 | }, 140 | expectedUptimeSLO: 9, 141 | }, 142 | { 143 | tag: CheckResponseTag{ 144 | Name: "no_uptime_slo_tag", 145 | Type: "a", 146 | Count: 2, 147 | }, 148 | expectedUptimeSLO: 91, 149 | }, 150 | } 151 | 152 | for _, testCase := range testCases { 153 | response := CheckResponse{ 154 | Tags: []CheckResponseTag{testCase.tag}, 155 | } 156 | 157 | uptimeSLO := response.UptimeSLOFromTags(91) 158 | assert.Equal(t, uptimeSLO, testCase.expectedUptimeSLO) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /pkg/pingdom/pingdom.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | const ( 12 | defaultBaseURL = "https://api.pingdom.com/api/3.1" 13 | ) 14 | 15 | // Client represents a client to the Pingdom API. This package also 16 | // provides a NewClient function for convenience to initialize a client 17 | // with default parameters. 18 | type Client struct { 19 | Token string 20 | BaseURL *url.URL 21 | client *http.Client 22 | 23 | Tags string 24 | 25 | Checks *CheckService 26 | OutageSummary *OutageSummaryService 27 | } 28 | 29 | // ClientConfig represents a configuration for a pingdom client. 30 | type ClientConfig struct { 31 | Token string 32 | Tags string 33 | BaseURL string 34 | HTTPClient *http.Client 35 | } 36 | 37 | // NewClientWithConfig returns a Pingdom client. 38 | func NewClientWithConfig(config ClientConfig) (*Client, error) { 39 | var baseURL *url.URL 40 | var err error 41 | if config.BaseURL != "" { 42 | baseURL, err = url.Parse(config.BaseURL) 43 | } else { 44 | baseURL, err = url.Parse(defaultBaseURL) 45 | } 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | c := &Client{ 51 | Token: config.Token, 52 | Tags: config.Tags, 53 | BaseURL: baseURL, 54 | } 55 | 56 | if config.HTTPClient != nil { 57 | c.client = config.HTTPClient 58 | } else { 59 | c.client = http.DefaultClient 60 | } 61 | 62 | c.Checks = &CheckService{client: c} 63 | c.OutageSummary = &OutageSummaryService{client: c} 64 | 65 | return c, nil 66 | } 67 | 68 | // NewRequest makes a new HTTP Request. The method param should be an HTTP method in 69 | // all caps such as GET, POST, PUT, DELETE. The rsc param should correspond with 70 | // a restful resource. Params can be passed in as a map of strings 71 | // Usually users of the client can use one of the convenience methods such as 72 | // ListChecks, etc but this method is provided to allow for making other 73 | // API calls that might not be built in. 74 | func (pc *Client) NewRequest(method string, rsc string, params map[string]string) (*http.Request, error) { 75 | baseURL, err := url.Parse(pc.BaseURL.String() + rsc) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | if params != nil { 81 | ps := url.Values{} 82 | for k, v := range params { 83 | ps.Set(k, v) 84 | } 85 | baseURL.RawQuery = ps.Encode() 86 | } 87 | 88 | req, err := http.NewRequest(method, baseURL.String(), nil) 89 | req.Header.Add("Authorization", "Bearer "+pc.Token) 90 | 91 | return req, err 92 | } 93 | 94 | // Do makes an HTTP request and will unmarshal the JSON response in to the 95 | // passed in interface. If the HTTP response is outside of the 2xx range the 96 | // response will be returned along with the error. 97 | func (pc *Client) Do(req *http.Request, v interface{}) (*http.Response, error) { 98 | resp, err := pc.client.Do(req) 99 | if err != nil { 100 | return nil, err 101 | } 102 | defer resp.Body.Close() 103 | 104 | if err := validateResponse(resp); err != nil { 105 | return resp, err 106 | } 107 | 108 | err = decodeResponse(resp, v) 109 | return resp, err 110 | } 111 | 112 | func decodeResponse(r *http.Response, v interface{}) error { 113 | if v == nil { 114 | return fmt.Errorf("nil interface provided to decodeResponse") 115 | } 116 | 117 | bodyBytes, _ := ioutil.ReadAll(r.Body) 118 | bodyString := string(bodyBytes) 119 | err := json.Unmarshal([]byte(bodyString), &v) 120 | return err 121 | } 122 | 123 | // Takes an HTTP response and determines whether it was successful. 124 | // Returns nil if the HTTP status code is within the 2xx range. Returns 125 | // an error otherwise. 126 | func validateResponse(r *http.Response) error { 127 | if c := r.StatusCode; 200 <= c && c <= 299 { 128 | return nil 129 | } 130 | 131 | bodyBytes, _ := ioutil.ReadAll(r.Body) 132 | bodyString := string(bodyBytes) 133 | m := &errorJSONResponse{} 134 | err := json.Unmarshal([]byte(bodyString), &m) 135 | if err != nil { 136 | return err 137 | } 138 | 139 | return m.Error 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pingdom Metrics Exporter for Prometheus 2 | 3 | Prometheus exporter for uptime metrics exposed by the Pingdom API. 4 | 5 | ## Running 6 | 7 | Make sure you expose the Pingdom API Token via the `PINGDOM_API_TOKEN` 8 | environment variable: 9 | 10 | ```sh 11 | # Expose the Pingdom API Token 12 | export PINGDOM_API_TOKEN= 13 | 14 | # Run the binary with the default options 15 | bin/pingdom-exporter 16 | ``` 17 | 18 | ### Usage 19 | 20 | ``` 21 | bin/pingdom-exporter -h 22 | 23 | Usage of bin/pingdom-exporter: 24 | -default-uptime-slo float 25 | default uptime SLO to be used when the check doesn't provide a uptime SLO tag (i.e. uptime_slo_999 to 99.9% uptime SLO) (default 99) 26 | -metrics-path string 27 | path under which to expose metrics (default "/metrics") 28 | -outage-check-period int 29 | time (in days) in which to retrieve outage data from the Pingdom API (default 7) 30 | -port int 31 | port to listen on (default 9158) 32 | -tags string 33 | tag list separated by commas 34 | ``` 35 | 36 | #### Supported Pingdom Tags 37 | 38 | ##### `uptime_slo_xxx` 39 | 40 | This will instruct pingdom-exporter to use a custom SLO for the given check 41 | instead of the default one of 99%. Some tag examples and their corresponding 42 | SLOs: 43 | 44 | - `uptime_slo_99` - 99%, same as default 45 | - `uptime_slo_995` - 99.5% 46 | - `uptime_slo_999` - 99.9% 47 | 48 | ##### `pingdom_exporter_ignored` 49 | 50 | Checks with this tag won't have their metrics exported. Use this when you don't 51 | want to disable some check just to have it excluded from the pingdom-exporter 52 | metrics. 53 | 54 | You can also set the `-tags` flag to only return metrics for checks that contain 55 | the given tags. 56 | 57 | ### Docker Image 58 | 59 | We no longer provide a public Docker image. See the **Development** section 60 | on how to build your own image and push it to your private registry. 61 | 62 | ## Exported Metrics 63 | 64 | | Metric Name | Description | 65 | | --------------------------------------------------- |----------------------------------------------------------------------------------------------------------| 66 | | `pingdom_up` | Was the last query on Pingdom API successful | 67 | | `pingdom_rate_limit_remaining_requests` | The remaining requests allowed before hitting the short-term or long-term rate limit in the Pingdom API. | 68 | | `pingdom_uptime_status` | The current status of the check (1: up, 0: down) | 69 | | `pingdom_uptime_response_time_seconds` | The response time of last test, in seconds | 70 | | `pingdom_slo_period_seconds` | Outage check period, in seconds (see `-outage-check-period` flag) | 71 | | `pingdom_outages_total` | Number of outages within the outage check period | 72 | | `pingdom_down_seconds` | Total down time within the outage check period, in seconds | 73 | | `pingdom_up_seconds` | Total up time within the outage check period, in seconds | 74 | | `pingdom_uptime_slo_error_budget_total_seconds` | Maximum number of allowed downtime, in seconds, according to the uptime SLO | 75 | | `pingdom_uptime_slo_error_budget_available_seconds` | Number of seconds of downtime we can still have without breaking the uptime SLO | 76 | 77 | ## Development 78 | 79 | All relevant commands are exposed via Makefile targets: 80 | 81 | ```sh 82 | # Build the binary 83 | make 84 | 85 | # Run the tests 86 | make test 87 | 88 | # Check linting rules 89 | make lint 90 | 91 | # Build Docker image 92 | make image 93 | 94 | # Push Docker images to registry 95 | make publish 96 | ``` 97 | -------------------------------------------------------------------------------- /pkg/pingdom/check_test.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestCheckServiceList(t *testing.T) { 13 | setup() 14 | defer teardown() 15 | 16 | mux.HandleFunc("/checks", func(w http.ResponseWriter, r *http.Request) { 17 | testMethod(t, r, "GET") 18 | w.Header().Set("req-limit-long", "Remaining: 12 Time until reset: 34") 19 | fmt.Fprint(w, `{ 20 | "checks": [ 21 | { 22 | "hostname": "example.com", 23 | "id": 85975, 24 | "lasterrortime": 1297446423, 25 | "lastresponsetime": 355, 26 | "lasttesttime": 1300977363, 27 | "name": "My check 1", 28 | "resolution": 1, 29 | "status": "up", 30 | "type": "http", 31 | "tags": [ 32 | { 33 | "name": "apache", 34 | "type": "a", 35 | "count": 2 36 | } 37 | ], 38 | "responsetime_threshold": 2300 39 | }, 40 | { 41 | "hostname": "mydomain.com", 42 | "id": 161748, 43 | "lasterrortime": 1299194968, 44 | "lastresponsetime": 1141, 45 | "lasttesttime": 1300977268, 46 | "name": "My check 2", 47 | "resolution": 5, 48 | "status": "up", 49 | "type": "ping", 50 | "tags": [ 51 | { 52 | "name": "nginx", 53 | "type": "u", 54 | "count": 1 55 | } 56 | ] 57 | }, 58 | { 59 | "hostname": "example.net", 60 | "id": 208655, 61 | "lasterrortime": 1300527997, 62 | "lastresponsetime": 800, 63 | "lasttesttime": 1300977337, 64 | "name": "My check 3", 65 | "resolution": 1, 66 | "status": "down", 67 | "type": "http", 68 | "tags": [ 69 | { 70 | "name": "apache", 71 | "type": "a", 72 | "count": 2 73 | } 74 | ] 75 | } 76 | ] 77 | }`) 78 | }) 79 | 80 | var countA, countB float64 = 1, 2 81 | 82 | want := []CheckResponse{ 83 | { 84 | ID: 85975, 85 | Name: "My check 1", 86 | LastErrorTime: 1297446423, 87 | LastResponseTime: 355, 88 | LastTestTime: 1300977363, 89 | Hostname: "example.com", 90 | Resolution: 1, 91 | Status: "up", 92 | ResponseTimeThreshold: 2300, 93 | Type: CheckResponseType{ 94 | Name: "http", 95 | }, 96 | Tags: []CheckResponseTag{ 97 | { 98 | Name: "apache", 99 | Type: "a", 100 | Count: countB, 101 | }, 102 | }, 103 | }, 104 | { 105 | ID: 161748, 106 | Name: "My check 2", 107 | LastErrorTime: 1299194968, 108 | LastResponseTime: 1141, 109 | LastTestTime: 1300977268, 110 | Hostname: "mydomain.com", 111 | Resolution: 5, 112 | Status: "up", 113 | Type: CheckResponseType{ 114 | Name: "ping", 115 | }, 116 | Tags: []CheckResponseTag{ 117 | { 118 | Name: "nginx", 119 | Type: "u", 120 | Count: countA, 121 | }, 122 | }, 123 | }, 124 | { 125 | ID: 208655, 126 | Name: "My check 3", 127 | LastErrorTime: 1300527997, 128 | LastResponseTime: 800, 129 | LastTestTime: 1300977337, 130 | Hostname: "example.net", 131 | Resolution: 1, 132 | Status: "down", 133 | Type: CheckResponseType{ 134 | Name: "http", 135 | }, 136 | Tags: []CheckResponseTag{ 137 | { 138 | Name: "apache", 139 | Type: "a", 140 | Count: countB, 141 | }, 142 | }, 143 | }, 144 | } 145 | 146 | checks, minRequestLimit, err := client.Checks.List() 147 | assert.NoError(t, err) 148 | assert.Equal(t, want, checks) 149 | assert.EqualValues(t, 12, minRequestLimit) 150 | } 151 | 152 | func TestMinRequestLimitFromResp(t *testing.T) { 153 | tc := []struct { 154 | header http.Header 155 | expected float64 156 | }{ 157 | { 158 | header: http.Header{}, 159 | expected: math.MaxFloat64, 160 | }, 161 | { 162 | header: http.Header{ 163 | "Req-Limit-Short": []string{"Remaining: 12 Time until reset: 34"}, 164 | }, 165 | expected: 12, 166 | }, 167 | { 168 | header: http.Header{ 169 | "Req-Limit-Long": []string{"Remaining: 56 Time until reset: 78"}, 170 | }, 171 | expected: 56, 172 | }, 173 | { 174 | header: http.Header{ 175 | "Req-Limit-Long": []string{"Remaining: 0 Time until reset: 78"}, 176 | "Req-Limit-Short": []string{"Remaining: 12 Time until reset: 34"}, 177 | }, 178 | expected: 0, 179 | }, 180 | { 181 | header: http.Header{ 182 | "Req-Limit-Long": []string{"invalid"}, 183 | }, 184 | expected: math.MaxFloat64, 185 | }, 186 | } 187 | 188 | for _, tt := range tc { 189 | t.Run(fmt.Sprintf("%v", tt.header), func(t *testing.T) { 190 | actual := minRequestLimitFromHeader(tt.header) 191 | assert.Equal(t, tt.expected, actual) 192 | }) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 4 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 12 | github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= 13 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 14 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 15 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 16 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 17 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 18 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 19 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 20 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 21 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 22 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 23 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 26 | github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= 27 | github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 28 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= 32 | github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= 33 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 34 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 35 | github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= 36 | github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= 37 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 38 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 39 | github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI= 40 | github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y= 41 | github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= 42 | github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= 43 | github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= 44 | github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= 45 | github.com/prometheus/procfs v0.14.0 h1:Lw4VdGGoKEZilJsayHf0B+9YgLGREba2C6xr+Fdfq6s= 46 | github.com/prometheus/procfs v0.14.0/go.mod h1:XL+Iwz8k8ZabyZfMFHPiilCniixqQarAy5Mu67pHlNQ= 47 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 48 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 49 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 51 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 52 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 53 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 54 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 55 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 56 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 57 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 58 | golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= 59 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 61 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 62 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 64 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 65 | google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= 66 | google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 67 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 68 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 69 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 70 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 71 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 72 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 74 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 75 | -------------------------------------------------------------------------------- /pkg/pingdom/api_responses.go: -------------------------------------------------------------------------------- 1 | package pingdom 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "math" 7 | "os" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // Uptime SLO tag format. 14 | var uptimeSLORegexp = regexp.MustCompile(`^uptime_slo_(?P\d+)$`) 15 | 16 | // Response represents a general response from the Pingdom API. 17 | type Response struct { 18 | Message string `json:"message"` 19 | } 20 | 21 | // Error represents an error response from the Pingdom API. 22 | type Error struct { 23 | StatusCode int `json:"statuscode"` 24 | StatusDesc string `json:"statusdesc"` 25 | Message string `json:"errormessage"` 26 | } 27 | 28 | // OutageSummaryResponse represents the JSON response for a outage summary list from the Pingdom API. 29 | type OutageSummaryResponse struct { 30 | States []OutageSummaryResponseState `json:"states"` 31 | } 32 | 33 | // OutageSummaryResponseState represents the JSON response for each outage summary. 34 | type OutageSummaryResponseState struct { 35 | Status string `json:"status"` 36 | FromTime int64 `json:"timefrom"` 37 | ToTime int64 `json:"timeto"` 38 | } 39 | 40 | // CheckResponse represents the JSON response for a check from the Pingdom API. 41 | type CheckResponse struct { 42 | ID int `json:"id"` 43 | Name string `json:"name"` 44 | Resolution int `json:"resolution,omitempty"` 45 | SendNotificationWhenDown int `json:"sendnotificationwhendown,omitempty"` 46 | NotifyAgainEvery int `json:"notifyagainevery,omitempty"` 47 | NotifyWhenBackup bool `json:"notifywhenbackup,omitempty"` 48 | Created int64 `json:"created,omitempty"` 49 | Hostname string `json:"hostname,omitempty"` 50 | Status string `json:"status,omitempty"` 51 | LastErrorTime int64 `json:"lasterrortime,omitempty"` 52 | LastTestTime int64 `json:"lasttesttime,omitempty"` 53 | LastResponseTime int64 `json:"lastresponsetime,omitempty"` 54 | IntegrationIds []int `json:"integrationids,omitempty"` 55 | SeverityLevel string `json:"severity_level,omitempty"` 56 | Type CheckResponseType `json:"type,omitempty"` 57 | Tags []CheckResponseTag `json:"tags,omitempty"` 58 | UserIds []int `json:"userids,omitempty"` 59 | Teams []CheckTeamResponse `json:"teams,omitempty"` 60 | ResponseTimeThreshold int `json:"responsetime_threshold,omitempty"` 61 | ProbeFilters []string `json:"probe_filters,omitempty"` 62 | } 63 | 64 | // CheckTeamResponse holds the team names for each check. 65 | type CheckTeamResponse struct { 66 | ID int `json:"id"` 67 | Name string `json:"name"` 68 | } 69 | 70 | // CheckResponseType is the type of the Pingdom check. 71 | type CheckResponseType struct { 72 | Name string `json:"-"` 73 | HTTP *CheckResponseHTTPDetails `json:"http,omitempty"` 74 | TCP *CheckResponseTCPDetails `json:"tcp,omitempty"` 75 | } 76 | 77 | // CheckResponseTag is an optional tag that can be added to checks. 78 | type CheckResponseTag struct { 79 | Name string `json:"name"` 80 | Type string `json:"type"` 81 | Count interface{} `json:"count"` 82 | } 83 | 84 | // SummaryPerformanceResponse represents the JSON response for a summary performance from the Pingdom API. 85 | type SummaryPerformanceResponse struct { 86 | Summary SummaryPerformanceMap `json:"summary"` 87 | } 88 | 89 | // SummaryPerformanceMap is the performance broken down over different time intervals. 90 | type SummaryPerformanceMap struct { 91 | Hours []SummaryPerformanceSummary `json:"hours,omitempty"` 92 | Days []SummaryPerformanceSummary `json:"days,omitempty"` 93 | Weeks []SummaryPerformanceSummary `json:"weeks,omitempty"` 94 | } 95 | 96 | // SummaryPerformanceSummary is the metrics for a performance summary. 97 | type SummaryPerformanceSummary struct { 98 | AvgResponse int `json:"avgresponse"` 99 | Downtime int `json:"downtime"` 100 | StartTime int `json:"starttime"` 101 | Unmonitored int `json:"unmonitored"` 102 | Uptime int `json:"uptime"` 103 | } 104 | 105 | // UnmarshalJSON converts a byte array into a CheckResponseType. 106 | func (c *CheckResponseType) UnmarshalJSON(b []byte) error { 107 | var raw interface{} 108 | 109 | err := json.Unmarshal(b, &raw) 110 | if err != nil { 111 | return err 112 | } 113 | 114 | switch v := raw.(type) { 115 | case string: 116 | c.Name = v 117 | case map[string]interface{}: 118 | if len(v) != 1 { 119 | return fmt.Errorf("Check detailed response `check.type` contains more than one object: %+v", v) 120 | } 121 | for k := range v { 122 | c.Name = k 123 | } 124 | 125 | // Allow continue use json.Unmarshall using a type != Unmarshaller 126 | // This avoid enter in a infinite loop 127 | type t CheckResponseType 128 | var rawCheckDetails t 129 | 130 | err := json.Unmarshal(b, &rawCheckDetails) 131 | if err != nil { 132 | return err 133 | } 134 | c.HTTP = rawCheckDetails.HTTP 135 | c.TCP = rawCheckDetails.TCP 136 | } 137 | return nil 138 | } 139 | 140 | // CheckResponseHTTPDetails represents the details specific to HTTP checks. 141 | type CheckResponseHTTPDetails struct { 142 | URL string `json:"url,omitempty"` 143 | Encryption bool `json:"encryption,omitempty"` 144 | Port int `json:"port,omitempty"` 145 | Username string `json:"username,omitempty"` 146 | Password string `json:"password,omitempty"` 147 | ShouldContain string `json:"shouldcontain,omitempty"` 148 | ShouldNotContain string `json:"shouldnotcontain,omitempty"` 149 | PostData string `json:"postdata,omitempty"` 150 | RequestHeaders map[string]string `json:"requestheaders,omitempty"` 151 | } 152 | 153 | // CheckResponseTCPDetails represents the details specific to TCP checks. 154 | type CheckResponseTCPDetails struct { 155 | Port int `json:"port,omitempty"` 156 | StringToSend string `json:"stringtosend,omitempty"` 157 | StringToExpect string `json:"stringtoexpect,omitempty"` 158 | } 159 | 160 | // Return string representation of Error. 161 | func (r *Error) Error() string { 162 | return fmt.Sprintf("%d %v: %v", r.StatusCode, r.StatusDesc, r.Message) 163 | } 164 | 165 | // TagsString returns the check tags as a comma-separated string. 166 | func (cr *CheckResponse) TagsString() string { 167 | var tagsRaw []string 168 | for _, tag := range cr.Tags { 169 | tagsRaw = append(tagsRaw, tag.Name) 170 | } 171 | return strings.Join(tagsRaw, ",") 172 | } 173 | 174 | // HasIgnoreTag returns true if the tag "pingdom_exporter_ignored" exists for 175 | // this check. 176 | func (cr *CheckResponse) HasIgnoreTag() bool { 177 | for _, tag := range cr.Tags { 178 | if tag.Name == "pingdom_exporter_ignored" { 179 | return true 180 | } 181 | } 182 | 183 | return false 184 | } 185 | 186 | // UptimeSLOFromTags returns the uptime SLO configured to this check via a tag, 187 | // i.e. "uptime_slo_999" for 99.9 uptime SLO. Returns the argument as the 188 | // default uptime SLO in case no uptime SLO tag exists for this check. 189 | func (cr *CheckResponse) UptimeSLOFromTags(defaultUptimeSLO float64) float64 { 190 | for _, tag := range cr.Tags { 191 | matches := uptimeSLORegexp.FindStringSubmatch(tag.Name) 192 | 193 | if len(matches) > 0 { 194 | n, err := strconv.ParseFloat(matches[1], 64) 195 | 196 | if err != nil { 197 | fmt.Fprintf(os.Stderr, "Error parsing uptime SLO tag %s: %v", matches[1], err) 198 | break 199 | } 200 | 201 | return n / math.Pow(10, math.Max(0, float64(len(matches[1])-2))) 202 | } 203 | } 204 | 205 | return defaultUptimeSLO 206 | } 207 | 208 | // private types used to unmarshall JSON responses from Pingdom. 209 | 210 | type listChecksJSONResponse struct { 211 | Checks []CheckResponse `json:"checks"` 212 | } 213 | 214 | type listOutageSummaryJSONResponse struct { 215 | Summary OutageSummaryResponse `json:"summary"` 216 | } 217 | 218 | type errorJSONResponse struct { 219 | Error *Error `json:"error"` 220 | } 221 | -------------------------------------------------------------------------------- /cmd/pingdom-exporter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | "sync" 11 | "time" 12 | 13 | "github.com/jusbrasil/pingdom-exporter/pkg/pingdom" 14 | "github.com/prometheus/client_golang/prometheus" 15 | "github.com/prometheus/client_golang/prometheus/promhttp" 16 | ) 17 | 18 | var ( 19 | // VERSION will hold the version number injected during the build. 20 | VERSION string 21 | 22 | token string 23 | tags string 24 | metricsPath string 25 | waitSeconds int 26 | port int 27 | outageCheckPeriod int 28 | defaultUptimeSLO float64 29 | 30 | pingdomUpDesc = prometheus.NewDesc( 31 | "pingdom_up", 32 | "Whether the last pingdom scrape was successfull (1: up, 0: down).", 33 | nil, nil, 34 | ) 35 | 36 | pingdomRateLimitRemainingRequestsDesc = prometheus.NewDesc( 37 | "pingdom_rate_limit_remaining_requests", 38 | "Tracks the remaining requests allowed before hitting the short-term or long-term rate limit in the Pingdom API.", 39 | nil, nil, 40 | ) 41 | 42 | pingdomOutageCheckPeriodDesc = prometheus.NewDesc( 43 | "pingdom_slo_period_seconds", 44 | "Outage check period, in seconds", 45 | nil, nil, 46 | ) 47 | 48 | pingdomCheckStatusDesc = prometheus.NewDesc( 49 | "pingdom_uptime_status", 50 | "The current status of the check (1: up, 0: down)", 51 | []string{"id", "name", "hostname", "status", "resolution", "paused", "tags"}, nil, 52 | ) 53 | 54 | pingdomCheckResponseTimeDesc = prometheus.NewDesc( 55 | "pingdom_uptime_response_time_seconds", 56 | "The response time of last test, in seconds", 57 | []string{"id", "name", "hostname", "status", "resolution", "paused", "tags"}, nil, 58 | ) 59 | 60 | pingdomOutagesDesc = prometheus.NewDesc( 61 | "pingdom_outages_total", 62 | "Number of outages within the outage check period", 63 | []string{"id", "name", "hostname", "tags"}, nil, 64 | ) 65 | 66 | pingdomCheckErrorBudgetDesc = prometheus.NewDesc( 67 | "pingdom_uptime_slo_error_budget_total_seconds", 68 | "Maximum number of allowed downtime, in seconds, according to the uptime SLO", 69 | []string{"id", "name", "hostname", "tags"}, nil, 70 | ) 71 | 72 | pingdomCheckAvailableErrorBudgetDesc = prometheus.NewDesc( 73 | "pingdom_uptime_slo_error_budget_available_seconds", 74 | "Number of seconds of downtime we can still have without breaking the uptime SLO", 75 | []string{"id", "name", "hostname", "tags"}, nil, 76 | ) 77 | 78 | pingdomDownTimeDesc = prometheus.NewDesc( 79 | "pingdom_down_seconds", 80 | "Total down time within the outage check period, in seconds", 81 | []string{"id", "name", "hostname", "tags"}, nil, 82 | ) 83 | 84 | pingdomUpTimeDesc = prometheus.NewDesc( 85 | "pingdom_up_seconds", 86 | "Total up time within the outage check period, in seconds", 87 | []string{"id", "name", "hostname", "tags"}, nil, 88 | ) 89 | ) 90 | 91 | func init() { 92 | flag.IntVar(&port, "port", 9158, "port to listen on") 93 | flag.IntVar(&outageCheckPeriod, "outage-check-period", 7, "time (in days) in which to retrieve outage data from the Pingdom API") 94 | flag.Float64Var(&defaultUptimeSLO, "default-uptime-slo", 99.0, "default uptime SLO to be used when the check doesn't provide a uptime SLO tag (i.e. uptime_slo_999 to 99.9% uptime SLO)") 95 | flag.StringVar(&metricsPath, "metrics-path", "/metrics", "path under which to expose metrics") 96 | flag.StringVar(&tags, "tags", "", "tag list separated by commas") 97 | } 98 | 99 | type pingdomCollector struct { 100 | client *pingdom.Client 101 | } 102 | 103 | func (pc pingdomCollector) Describe(ch chan<- *prometheus.Desc) { 104 | ch <- pingdomUpDesc 105 | ch <- pingdomRateLimitRemainingRequestsDesc 106 | ch <- pingdomOutageCheckPeriodDesc 107 | ch <- pingdomCheckStatusDesc 108 | ch <- pingdomCheckResponseTimeDesc 109 | ch <- pingdomCheckAvailableErrorBudgetDesc 110 | ch <- pingdomCheckErrorBudgetDesc 111 | ch <- pingdomDownTimeDesc 112 | ch <- pingdomUpTimeDesc 113 | ch <- pingdomOutagesDesc 114 | } 115 | 116 | func (pc pingdomCollector) Collect(ch chan<- prometheus.Metric) { 117 | outageCheckPeriodDuration := time.Hour * time.Duration(24*outageCheckPeriod) 118 | outageCheckPeriodSecs := float64(outageCheckPeriodDuration / time.Second) 119 | 120 | checks, minReqLimit, err := pc.client.Checks.List(map[string]string{ 121 | "include_tags": "true", 122 | "tags": pc.client.Tags, 123 | }) 124 | 125 | ch <- prometheus.MustNewConstMetric( 126 | pingdomRateLimitRemainingRequestsDesc, 127 | prometheus.GaugeValue, 128 | minReqLimit, 129 | ) 130 | 131 | if err != nil { 132 | fmt.Fprintf(os.Stderr, "Error getting checks: %v", err) 133 | ch <- prometheus.MustNewConstMetric( 134 | pingdomUpDesc, 135 | prometheus.GaugeValue, 136 | float64(0), 137 | ) 138 | return 139 | } 140 | 141 | ch <- prometheus.MustNewConstMetric( 142 | pingdomUpDesc, 143 | prometheus.GaugeValue, 144 | float64(1), 145 | ) 146 | 147 | ch <- prometheus.MustNewConstMetric( 148 | pingdomOutageCheckPeriodDesc, 149 | prometheus.GaugeValue, 150 | outageCheckPeriodSecs, 151 | ) 152 | 153 | var wg sync.WaitGroup 154 | 155 | for _, check := range checks { 156 | 157 | // Ignore this check based on the presence of the ignore label 158 | if check.HasIgnoreTag() { 159 | continue 160 | } 161 | 162 | id := strconv.Itoa(check.ID) 163 | tags := check.TagsString() 164 | resolution := strconv.Itoa(check.Resolution) 165 | 166 | var status float64 167 | paused := "false" 168 | if check.Status == "paused" { 169 | paused = "true" 170 | } else if check.Status == "up" { 171 | status = 1 172 | } 173 | 174 | ch <- prometheus.MustNewConstMetric( 175 | pingdomCheckStatusDesc, 176 | prometheus.GaugeValue, 177 | status, 178 | id, 179 | check.Name, 180 | check.Hostname, 181 | check.Status, 182 | resolution, 183 | paused, 184 | tags, 185 | ) 186 | 187 | ch <- prometheus.MustNewConstMetric( 188 | pingdomCheckResponseTimeDesc, 189 | prometheus.GaugeValue, 190 | float64(check.LastResponseTime)/1000.0, 191 | id, 192 | check.Name, 193 | check.Hostname, 194 | check.Status, 195 | resolution, 196 | paused, 197 | tags, 198 | ) 199 | 200 | // Retrieve outages for check 201 | var downCount, upTime, downTime float64 202 | 203 | // Maximum allowed downtime, in seconds, according to the uptime SLO 204 | uptimeErrorBudget := outageCheckPeriodSecs * (100.0 - check.UptimeSLOFromTags(defaultUptimeSLO)) / 100.0 205 | 206 | // Retrieve the outage list within the desired period for this check, in background 207 | wg.Add(1) 208 | 209 | go func(check pingdom.CheckResponse) { 210 | defer wg.Done() 211 | 212 | // Retrieve the list of outages within the outage period for the given check 213 | now := time.Now() 214 | states, err := pc.client.OutageSummary.List(check.ID, map[string]string{ 215 | "from": strconv.FormatInt(now.Add(-outageCheckPeriodDuration).Unix(), 10), 216 | "to": strconv.FormatInt(now.Unix(), 10), 217 | }) 218 | 219 | if err != nil { 220 | fmt.Fprintf(os.Stderr, "Error getting outages for check %d: %v", check.ID, err) 221 | return 222 | } 223 | 224 | for _, state := range states { 225 | switch state.Status { 226 | case "down": 227 | downCount = downCount + 1 228 | downTime = downTime + float64(state.ToTime-state.FromTime) 229 | case "up": 230 | upTime = upTime + float64(state.ToTime-state.FromTime) 231 | 232 | } 233 | } 234 | 235 | ch <- prometheus.MustNewConstMetric( 236 | pingdomOutagesDesc, 237 | prometheus.GaugeValue, 238 | downCount, 239 | id, 240 | check.Name, 241 | check.Hostname, 242 | tags, 243 | ) 244 | 245 | ch <- prometheus.MustNewConstMetric( 246 | pingdomUpTimeDesc, 247 | prometheus.GaugeValue, 248 | upTime, 249 | id, 250 | check.Name, 251 | check.Hostname, 252 | tags, 253 | ) 254 | 255 | ch <- prometheus.MustNewConstMetric( 256 | pingdomDownTimeDesc, 257 | prometheus.GaugeValue, 258 | downTime, 259 | id, 260 | check.Name, 261 | check.Hostname, 262 | tags, 263 | ) 264 | 265 | ch <- prometheus.MustNewConstMetric( 266 | pingdomCheckErrorBudgetDesc, 267 | prometheus.GaugeValue, 268 | uptimeErrorBudget, 269 | id, 270 | check.Name, 271 | check.Hostname, 272 | tags, 273 | ) 274 | 275 | ch <- prometheus.MustNewConstMetric( 276 | pingdomCheckAvailableErrorBudgetDesc, 277 | prometheus.GaugeValue, 278 | uptimeErrorBudget-downTime, 279 | id, 280 | check.Name, 281 | check.Hostname, 282 | tags, 283 | ) 284 | }(check) 285 | } 286 | 287 | wg.Wait() 288 | } 289 | 290 | func main() { 291 | var client *pingdom.Client 292 | flag.Parse() 293 | 294 | token = os.Getenv("PINGDOM_API_TOKEN") 295 | if token == "" { 296 | fmt.Fprintln(os.Stderr, "Pingdom API token must be provided via the PINGDOM_API_TOKEN environment variable, exiting") 297 | os.Exit(1) 298 | } 299 | 300 | client, err := pingdom.NewClientWithConfig(pingdom.ClientConfig{ 301 | Token: token, 302 | Tags: tags, 303 | }) 304 | 305 | if err != nil { 306 | fmt.Fprintln(os.Stderr, "Cannot create Pingdom client, exiting") 307 | os.Exit(1) 308 | } 309 | 310 | registry := prometheus.NewPedanticRegistry() 311 | collector := pingdomCollector{ 312 | client: client, 313 | } 314 | 315 | registry.MustRegister( 316 | collector, 317 | prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}), 318 | prometheus.NewGoCollector(), 319 | ) 320 | 321 | http.Handle(metricsPath, promhttp.HandlerFor(registry, promhttp.HandlerOpts{})) 322 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 323 | w.Write([]byte(` 324 | Pingdom Exporter 325 | 326 |

Pingdom Exporter

327 |

Metrics

328 | 329 | `)) 330 | }) 331 | 332 | fmt.Fprintf(os.Stdout, "Pingdom Exporter %v listening on http://0.0.0.0:%v\n", VERSION, port) 333 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), nil)) 334 | } 335 | --------------------------------------------------------------------------------