├── .github └── workflows │ └── build.yaml ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── clickhouse_exporter.go ├── docker-compose.yml ├── docker └── prometheus │ └── prometheus.yml ├── exporter ├── exporter.go └── exporter_test.go ├── go.mod └── go.sum /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - dev 8 | 9 | push: 10 | branches: 11 | - master 12 | - dev 13 | - github_actions 14 | 15 | jobs: 16 | build: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout project 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup golang 24 | id: setup-go 25 | uses: actions/setup-go@v4 26 | with: 27 | go-version: '^1.23' 28 | 29 | - name: Cache golang 30 | id: cache-golang 31 | uses: actions/cache@v3 32 | with: 33 | path: | 34 | ~/go/pkg/mod 35 | ~/.cache/go-build 36 | key: ${{ runner.os }}-${{ matrix.golang-version }}-golang-${{ hashFiles('go.sum') }} 37 | restore-keys: | 38 | ${{ runner.os }}-${{ matrix.golang-version }}-golang- 39 | 40 | - name: Install golang dependencies 41 | run: | 42 | go mod download -x 43 | go install github.com/AlekSi/gocoverutil@latest 44 | if: | 45 | steps.cache-golang.outputs.cache-hit != 'true' 46 | 47 | - run: make init 48 | - run: make 49 | 50 | - name: Docker build 51 | run: | 52 | docker build -t clickhouse/clickhouse_exporter:latest . 53 | docker run --rm clickhouse/clickhouse_exporter:latest --help 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .build/ 3 | clickhouse_exporter 4 | coverage.txt 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: go 3 | 4 | services: 5 | - docker 6 | 7 | go: 8 | - 1.16.x 9 | - tip 10 | 11 | go_import_path: github.com/ClickHouse/clickhouse_exporter 12 | 13 | # skip non-trunk PMM-XXXX branch builds, but still build pull requests 14 | branches: 15 | except: 16 | - /^PMM\-\d{4}/ 17 | 18 | matrix: 19 | fast_finish: true 20 | allow_failures: 21 | - go: tip 22 | 23 | cache: 24 | directories: 25 | - /home/travis/.cache/go-build 26 | # - /home/travis/gopath/pkg 27 | 28 | before_cache: 29 | - go clean -testcache 30 | # - go clean -cache 31 | 32 | before_script: 33 | - docker --version 34 | - docker-compose --version 35 | - docker-compose up -d 36 | 37 | - make init 38 | 39 | script: 40 | - make 41 | 42 | after_success: 43 | - bash <(curl -s https://codecov.io/bash) -X fix 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing notes 2 | 3 | ## Local setup 4 | 5 | The easiest way to make a local development setup is to use Docker Compose. 6 | 7 | ``` 8 | docker-compose up 9 | make init 10 | make 11 | ``` 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS BUILDER 2 | 3 | LABEL maintainer="Eugene Klimov " 4 | 5 | COPY . /go/src/github.com/ClickHouse/clickhouse_exporter 6 | 7 | WORKDIR /go/src/github.com/ClickHouse/clickhouse_exporter 8 | 9 | RUN make init 10 | RUN make all 11 | 12 | FROM alpine:latest 13 | 14 | COPY --from=BUILDER /go/bin/clickhouse_exporter /usr/local/bin/clickhouse_exporter 15 | RUN apk update && apk add ca-certificates libc6-compat && rm -rf /var/cache/apk/* 16 | 17 | ENTRYPOINT ["/usr/local/bin/clickhouse_exporter"] 18 | CMD ["-scrape_uri=http://localhost:8123"] 19 | USER nobody 20 | EXPOSE 9116 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Yegor 'f1yegor' Andreenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: build test 2 | 3 | init: 4 | go install github.com/AlekSi/gocoverutil@latest 5 | 6 | build: 7 | go install -v 8 | go build -ldflags "-linkmode=external -extldflags '-static' -extldflags '-static'" . 9 | 10 | test: 11 | go test -v -race 12 | gocoverutil -coverprofile=coverage.txt test -v 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clickhouse Exporter for Prometheus (old clickhouse-server versions) 2 | 3 | This is a simple server that periodically scrapes [ClickHouse](https://clickhouse.com/) stats and exports them via HTTP for [Prometheus](https://prometheus.io/) 4 | consumption. 5 | 6 | Exporter could used only for old ClickHouse versions, modern versions have embedded prometheus endpoint. 7 | Look details https://clickhouse.com/docs/en/operations/server-configuration-parameters/settings#server_configuration_parameters-prometheus 8 | 9 | To run it: 10 | 11 | ```bash 12 | ./clickhouse_exporter [flags] 13 | ``` 14 | 15 | Help on flags: 16 | ```bash 17 | ./clickhouse_exporter --help 18 | ``` 19 | 20 | Credentials(if not default): 21 | 22 | via environment variables 23 | ``` 24 | CLICKHOUSE_USER 25 | CLICKHOUSE_PASSWORD 26 | ``` 27 | 28 | ## Build Docker image 29 | ``` 30 | docker build . -t clickhouse-exporter 31 | ``` 32 | 33 | ## Using Docker 34 | 35 | ``` 36 | docker run -d -p 9116:9116 clickhouse-exporter -scrape_uri=http://clickhouse-url:8123/ 37 | ``` 38 | ## Sample dashboard 39 | Grafana dashboard could be a start for inspiration https://grafana.com/grafana/dashboards/882-clickhouse 40 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1 2 | -------------------------------------------------------------------------------- /clickhouse_exporter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "net/http" 6 | "net/url" 7 | "os" 8 | 9 | "github.com/ClickHouse/clickhouse_exporter/exporter" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | "github.com/rs/zerolog/log" 13 | ) 14 | 15 | var ( 16 | listeningAddress = flag.String("telemetry.address", ":9116", "Address on which to expose metrics.") 17 | metricsEndpoint = flag.String("telemetry.endpoint", "/metrics", "Path under which to expose metrics.") 18 | clickhouseScrapeURI = flag.String("scrape_uri", "http://localhost:8123/", "URI to clickhouse http endpoint") 19 | clickhouseOnly = flag.Bool("clickhouse_only", false, "Expose only Clickhouse metrics, not metrics from the exporter itself") 20 | insecure = flag.Bool("insecure", true, "Ignore server certificate if using https") 21 | user = os.Getenv("CLICKHOUSE_USER") 22 | password = os.Getenv("CLICKHOUSE_PASSWORD") 23 | ) 24 | 25 | func main() { 26 | flag.Parse() 27 | 28 | uri, err := url.Parse(*clickhouseScrapeURI) 29 | if err != nil { 30 | log.Fatal().Err(err).Send() 31 | } 32 | log.Printf("Scraping %s", *clickhouseScrapeURI) 33 | 34 | registerer := prometheus.DefaultRegisterer 35 | gatherer := prometheus.DefaultGatherer 36 | if *clickhouseOnly { 37 | reg := prometheus.NewRegistry() 38 | registerer = reg 39 | gatherer = reg 40 | } 41 | 42 | e := exporter.NewExporter(*uri, *insecure, user, password) 43 | registerer.MustRegister(e) 44 | 45 | http.Handle(*metricsEndpoint, promhttp.HandlerFor(gatherer, promhttp.HandlerOpts{})) 46 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 47 | w.Write([]byte(` 48 | Clickhouse Exporter 49 | 50 |

Clickhouse Exporter

51 |

Metrics

52 | 53 | `)) 54 | }) 55 | 56 | log.Fatal().Err(http.ListenAndServe(*listeningAddress, nil)).Send() 57 | } 58 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | services: 4 | clickhouse: 5 | image: clickhouse/clickhouse-server 6 | ports: 7 | - 127.0.0.1:8123:8123 8 | - 127.0.0.1:9000:9000 9 | - 127.0.0.1:9009:9009 10 | -------------------------------------------------------------------------------- /docker/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | --- 2 | global: 3 | scrape_interval: 1s 4 | evaluation_interval: 1s 5 | 6 | scrape_configs: 7 | - job_name: prometheus 8 | static_configs: 9 | - targets: ['127.0.0.1:9090'] 10 | 11 | - job_name: clickhouse 12 | static_configs: 13 | - targets: ['127.0.0.1:9116'] 14 | -------------------------------------------------------------------------------- /exporter/exporter.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | "time" 12 | "unicode" 13 | 14 | "github.com/prometheus/client_golang/prometheus" 15 | "github.com/rs/zerolog/log" 16 | ) 17 | 18 | const ( 19 | namespace = "clickhouse" // For Prometheus metrics. 20 | ) 21 | 22 | // Exporter collects clickhouse stats from the given URI and exports them using 23 | // the prometheus metrics package. 24 | type Exporter struct { 25 | metricsURI string 26 | asyncMetricsURI string 27 | eventsURI string 28 | partsURI string 29 | disksMetricURI string 30 | client *http.Client 31 | 32 | scrapeFailures prometheus.Counter 33 | 34 | user string 35 | password string 36 | } 37 | 38 | // NewExporter returns an initialized Exporter. 39 | func NewExporter(uri url.URL, insecure bool, user, password string) *Exporter { 40 | q := uri.Query() 41 | metricsURI := uri 42 | q.Set("query", "select metric, value from system.metrics") 43 | metricsURI.RawQuery = q.Encode() 44 | 45 | asyncMetricsURI := uri 46 | q.Set("query", "select replaceRegexpAll(toString(metric), '-', '_') AS metric, value from system.asynchronous_metrics") 47 | asyncMetricsURI.RawQuery = q.Encode() 48 | 49 | eventsURI := uri 50 | q.Set("query", "select event, value from system.events") 51 | eventsURI.RawQuery = q.Encode() 52 | 53 | partsURI := uri 54 | q.Set("query", "select database, table, sum(bytes) as bytes, count() as parts, sum(rows) as rows from system.parts where active = 1 group by database, table") 55 | partsURI.RawQuery = q.Encode() 56 | 57 | disksMetricURI := uri 58 | q.Set("query", `select name, sum(free_space) as free_space_in_bytes, sum(total_space) as total_space_in_bytes from system.disks group by name`) 59 | disksMetricURI.RawQuery = q.Encode() 60 | 61 | return &Exporter{ 62 | metricsURI: metricsURI.String(), 63 | asyncMetricsURI: asyncMetricsURI.String(), 64 | eventsURI: eventsURI.String(), 65 | partsURI: partsURI.String(), 66 | disksMetricURI: disksMetricURI.String(), 67 | scrapeFailures: prometheus.NewCounter(prometheus.CounterOpts{ 68 | Namespace: namespace, 69 | Name: "exporter_scrape_failures_total", 70 | Help: "Number of errors while scraping clickhouse.", 71 | }), 72 | client: &http.Client{ 73 | Transport: &http.Transport{ 74 | TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, 75 | }, 76 | Timeout: 30 * time.Second, 77 | }, 78 | user: user, 79 | password: password, 80 | } 81 | } 82 | 83 | // Describe describes all the metrics ever exported by the clickhouse exporter. It 84 | // implements prometheus.Collector. 85 | func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { 86 | // We cannot know in advance what metrics the exporter will generate 87 | // from clickhouse. So we use the poor man's describe method: Run a collect 88 | // and send the descriptors of all the collected metrics. 89 | 90 | metricCh := make(chan prometheus.Metric) 91 | doneCh := make(chan struct{}) 92 | 93 | go func() { 94 | for m := range metricCh { 95 | ch <- m.Desc() 96 | } 97 | close(doneCh) 98 | }() 99 | 100 | e.Collect(metricCh) 101 | close(metricCh) 102 | <-doneCh 103 | } 104 | 105 | func (e *Exporter) collect(ch chan<- prometheus.Metric) error { 106 | metrics, err := e.parseKeyValueResponse(e.metricsURI) 107 | if err != nil { 108 | return fmt.Errorf("error scraping clickhouse url %v: %v", e.metricsURI, err) 109 | } 110 | 111 | for _, m := range metrics { 112 | newMetric := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 113 | Namespace: namespace, 114 | Name: metricName(m.key), 115 | Help: "Number of " + m.key + " currently processed", 116 | }, []string{}).WithLabelValues() 117 | newMetric.Set(m.value) 118 | newMetric.Collect(ch) 119 | } 120 | 121 | asyncMetrics, err := e.parseKeyValueResponse(e.asyncMetricsURI) 122 | if err != nil { 123 | return fmt.Errorf("error scraping clickhouse url %v: %v", e.asyncMetricsURI, err) 124 | } 125 | 126 | for _, am := range asyncMetrics { 127 | newMetric := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 128 | Namespace: namespace, 129 | Name: metricName(am.key), 130 | Help: "Number of " + am.key + " async processed", 131 | }, []string{}).WithLabelValues() 132 | newMetric.Set(am.value) 133 | newMetric.Collect(ch) 134 | } 135 | 136 | events, err := e.parseKeyValueResponse(e.eventsURI) 137 | if err != nil { 138 | return fmt.Errorf("error scraping clickhouse url %v: %v", e.eventsURI, err) 139 | } 140 | 141 | for _, ev := range events { 142 | newMetric, _ := prometheus.NewConstMetric( 143 | prometheus.NewDesc( 144 | namespace+"_"+metricName(ev.key)+"_total", 145 | "Number of "+ev.key+" total processed", []string{}, nil), 146 | prometheus.CounterValue, float64(ev.value)) 147 | ch <- newMetric 148 | } 149 | 150 | parts, err := e.parsePartsResponse(e.partsURI) 151 | if err != nil { 152 | return fmt.Errorf("error scraping clickhouse url %v: %v", e.partsURI, err) 153 | } 154 | 155 | for _, part := range parts { 156 | newBytesMetric := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 157 | Namespace: namespace, 158 | Name: "table_parts_bytes", 159 | Help: "Table size in bytes", 160 | }, []string{"database", "table"}).WithLabelValues(part.database, part.table) 161 | newBytesMetric.Set(float64(part.bytes)) 162 | newBytesMetric.Collect(ch) 163 | 164 | newCountMetric := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 165 | Namespace: namespace, 166 | Name: "table_parts_count", 167 | Help: "Number of parts of the table", 168 | }, []string{"database", "table"}).WithLabelValues(part.database, part.table) 169 | newCountMetric.Set(float64(part.parts)) 170 | newCountMetric.Collect(ch) 171 | 172 | newRowsMetric := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 173 | Namespace: namespace, 174 | Name: "table_parts_rows", 175 | Help: "Number of rows in the table", 176 | }, []string{"database", "table"}).WithLabelValues(part.database, part.table) 177 | newRowsMetric.Set(float64(part.rows)) 178 | newRowsMetric.Collect(ch) 179 | } 180 | 181 | disksMetrics, err := e.parseDiskResponse(e.disksMetricURI) 182 | if err != nil { 183 | return fmt.Errorf("error scraping clickhouse url %v: %v", e.disksMetricURI, err) 184 | } 185 | 186 | for _, dm := range disksMetrics { 187 | newFreeSpaceMetric := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 188 | Namespace: namespace, 189 | Name: "free_space_in_bytes", 190 | Help: "Disks free_space_in_bytes capacity", 191 | }, []string{"disk"}).WithLabelValues(dm.disk) 192 | newFreeSpaceMetric.Set(dm.freeSpace) 193 | newFreeSpaceMetric.Collect(ch) 194 | 195 | newTotalSpaceMetric := prometheus.NewGaugeVec(prometheus.GaugeOpts{ 196 | Namespace: namespace, 197 | Name: "total_space_in_bytes", 198 | Help: "Disks total_space_in_bytes capacity", 199 | }, []string{"disk"}).WithLabelValues(dm.disk) 200 | newTotalSpaceMetric.Set(dm.totalSpace) 201 | newTotalSpaceMetric.Collect(ch) 202 | } 203 | 204 | return nil 205 | } 206 | 207 | func (e *Exporter) handleResponse(uri string) ([]byte, error) { 208 | req, err := http.NewRequest("GET", uri, nil) 209 | if err != nil { 210 | return nil, err 211 | } 212 | if e.user != "" && e.password != "" { 213 | req.Header.Set("X-ClickHouse-User", e.user) 214 | req.Header.Set("X-ClickHouse-Key", e.password) 215 | } 216 | resp, err := e.client.Do(req) 217 | if err != nil { 218 | return nil, fmt.Errorf("error scraping clickhouse: %v", err) 219 | } 220 | defer func() { 221 | if err := resp.Body.Close(); err != nil { 222 | log.Error().Err(err).Msg("can't close resp.Body") 223 | } 224 | }() 225 | 226 | data, err := io.ReadAll(resp.Body) 227 | if resp.StatusCode < 200 || resp.StatusCode >= 400 { 228 | if err != nil { 229 | data = []byte(err.Error()) 230 | } 231 | return nil, fmt.Errorf("status %s (%d): %s", resp.Status, resp.StatusCode, data) 232 | } 233 | 234 | return data, nil 235 | } 236 | 237 | type lineResult struct { 238 | key string 239 | value float64 240 | } 241 | 242 | func parseNumber(s string) (float64, error) { 243 | v, err := strconv.ParseFloat(s, 64) 244 | if err != nil { 245 | return 0, err 246 | } 247 | 248 | return v, nil 249 | } 250 | 251 | func (e *Exporter) parseKeyValueResponse(uri string) ([]lineResult, error) { 252 | data, err := e.handleResponse(uri) 253 | if err != nil { 254 | return nil, err 255 | } 256 | 257 | // Parsing results 258 | lines := strings.Split(string(data), "\n") 259 | var results = make([]lineResult, 0) 260 | 261 | for i, line := range lines { 262 | parts := strings.Fields(line) 263 | if len(parts) == 0 { 264 | continue 265 | } 266 | if len(parts) != 2 { 267 | return nil, fmt.Errorf("parseKeyValueResponse: unexpected %d line: %s", i, line) 268 | } 269 | k := strings.TrimSpace(parts[0]) 270 | v, err := parseNumber(strings.TrimSpace(parts[1])) 271 | if err != nil { 272 | return nil, err 273 | } 274 | results = append(results, lineResult{k, v}) 275 | 276 | } 277 | return results, nil 278 | } 279 | 280 | type diskResult struct { 281 | disk string 282 | freeSpace float64 283 | totalSpace float64 284 | } 285 | 286 | func (e *Exporter) parseDiskResponse(uri string) ([]diskResult, error) { 287 | data, err := e.handleResponse(uri) 288 | if err != nil { 289 | return nil, err 290 | } 291 | 292 | // Parsing results 293 | lines := strings.Split(string(data), "\n") 294 | var results = make([]diskResult, 0) 295 | 296 | for i, line := range lines { 297 | parts := strings.Fields(line) 298 | if len(parts) == 0 { 299 | continue 300 | } 301 | if len(parts) != 3 { 302 | return nil, fmt.Errorf("parseDiskResponse: unexpected %d line: %s", i, line) 303 | } 304 | disk := strings.TrimSpace(parts[0]) 305 | 306 | freeSpace, err := parseNumber(strings.TrimSpace(parts[1])) 307 | if err != nil { 308 | return nil, err 309 | } 310 | 311 | totalSpace, err := parseNumber(strings.TrimSpace(parts[2])) 312 | if err != nil { 313 | return nil, err 314 | } 315 | 316 | results = append(results, diskResult{disk, freeSpace, totalSpace}) 317 | 318 | } 319 | return results, nil 320 | } 321 | 322 | type partsResult struct { 323 | database string 324 | table string 325 | bytes int 326 | parts int 327 | rows int 328 | } 329 | 330 | func (e *Exporter) parsePartsResponse(uri string) ([]partsResult, error) { 331 | data, err := e.handleResponse(uri) 332 | if err != nil { 333 | return nil, err 334 | } 335 | 336 | // Parsing results 337 | lines := strings.Split(string(data), "\n") 338 | var results = make([]partsResult, 0) 339 | 340 | for i, line := range lines { 341 | parts := strings.Fields(line) 342 | if len(parts) == 0 { 343 | continue 344 | } 345 | if len(parts) != 5 { 346 | return nil, fmt.Errorf("parsePartsResponse: unexpected %d line: %s", i, line) 347 | } 348 | database := strings.TrimSpace(parts[0]) 349 | table := strings.TrimSpace(parts[1]) 350 | 351 | bytes, err := strconv.Atoi(strings.TrimSpace(parts[2])) 352 | if err != nil { 353 | return nil, err 354 | } 355 | 356 | count, err := strconv.Atoi(strings.TrimSpace(parts[3])) 357 | if err != nil { 358 | return nil, err 359 | } 360 | 361 | rows, err := strconv.Atoi(strings.TrimSpace(parts[4])) 362 | if err != nil { 363 | return nil, err 364 | } 365 | 366 | results = append(results, partsResult{database, table, bytes, count, rows}) 367 | } 368 | 369 | return results, nil 370 | } 371 | 372 | // Collect fetches the stats from configured clickhouse location and delivers them 373 | // as Prometheus metrics. It implements prometheus.Collector. 374 | func (e *Exporter) Collect(ch chan<- prometheus.Metric) { 375 | upValue := 1 376 | 377 | if err := e.collect(ch); err != nil { 378 | log.Info().Msgf("Error scraping clickhouse: %s", err) 379 | e.scrapeFailures.Inc() 380 | e.scrapeFailures.Collect(ch) 381 | 382 | upValue = 0 383 | } 384 | 385 | ch <- prometheus.MustNewConstMetric( 386 | prometheus.NewDesc( 387 | prometheus.BuildFQName(namespace, "", "up"), 388 | "Was the last query of ClickHouse successful.", 389 | nil, nil, 390 | ), 391 | prometheus.GaugeValue, float64(upValue), 392 | ) 393 | 394 | } 395 | 396 | func metricName(in string) string { 397 | out := toSnake(in) 398 | return strings.Replace(out, ".", "_", -1) 399 | } 400 | 401 | // toSnake convert the given string to snake case following the Golang format: 402 | // acronyms are converted to lower-case and preceded by an underscore. 403 | func toSnake(in string) string { 404 | runes := []rune(in) 405 | length := len(runes) 406 | 407 | var out []rune 408 | for i := 0; i < length; i++ { 409 | if i > 0 && unicode.IsUpper(runes[i]) && ((i+1 < length && unicode.IsLower(runes[i+1])) || unicode.IsLower(runes[i-1])) { 410 | out = append(out, '_') 411 | } 412 | out = append(out, unicode.ToLower(runes[i])) 413 | } 414 | 415 | return string(out) 416 | } 417 | 418 | // check interface 419 | var _ prometheus.Collector = (*Exporter)(nil) 420 | -------------------------------------------------------------------------------- /exporter/exporter_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | ) 9 | 10 | func TestScrape(t *testing.T) { 11 | clickhouseUrl, err := url.Parse("http://127.0.0.1:8123/") 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | exporter := NewExporter(*clickhouseUrl, false, "", "") 16 | 17 | t.Run("Describe", func(t *testing.T) { 18 | ch := make(chan *prometheus.Desc) 19 | go func() { 20 | exporter.Describe(ch) 21 | close(ch) 22 | }() 23 | 24 | for range ch { 25 | } 26 | }) 27 | 28 | t.Run("Collect", func(t *testing.T) { 29 | ch := make(chan prometheus.Metric) 30 | var err error 31 | go func() { 32 | err = exporter.collect(ch) 33 | if err != nil { 34 | panic("failed") 35 | } 36 | close(ch) 37 | }() 38 | 39 | for range ch { 40 | } 41 | }) 42 | } 43 | 44 | func TestParseNumber(t *testing.T) { 45 | type testCase struct { 46 | in string 47 | out float64 48 | } 49 | 50 | testCases := []testCase{ 51 | {in: "1", out: 1}, 52 | {in: "1.1", out: 1.1}, 53 | } 54 | 55 | for _, tc := range testCases { 56 | out, err := parseNumber(tc.in) 57 | if err != nil { 58 | t.Fatalf("unexpected error: %s", err) 59 | } 60 | if out != tc.out { 61 | t.Fatalf("wrong output: %f, expected %f", out, tc.out) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ClickHouse/clickhouse_exporter 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/prometheus/client_golang v1.20.5 7 | github.com/rs/zerolog v1.33.0 8 | ) 9 | 10 | require ( 11 | github.com/beorn7/perks v1.0.1 // indirect 12 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 13 | github.com/klauspost/compress v1.17.11 // indirect 14 | github.com/mattn/go-colorable v0.1.13 // indirect 15 | github.com/mattn/go-isatty v0.0.20 // indirect 16 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 17 | github.com/prometheus/client_model v0.6.1 // indirect 18 | github.com/prometheus/common v0.61.0 // indirect 19 | github.com/prometheus/procfs v0.15.1 // indirect 20 | golang.org/x/sys v0.28.0 // indirect 21 | google.golang.org/protobuf v1.35.2 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 6 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 7 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 8 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 10 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 11 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 12 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 13 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 14 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 15 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 16 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 17 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 18 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 19 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 20 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 21 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 22 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 23 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 24 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 25 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 26 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 27 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 28 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 29 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 30 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 31 | github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= 32 | github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= 33 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 34 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 35 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 36 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 37 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 38 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 42 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 43 | google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= 44 | google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 45 | --------------------------------------------------------------------------------