├── .dockerignore ├── .github └── workflows │ └── go.yml ├── .gitignore ├── .goreleaser.yml ├── .goreleaser └── Dockerfile ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── health.go └── server.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── internal ├── collector │ ├── exporter.go │ ├── exporter_test.go │ ├── metrics.go │ └── registry.go ├── config │ └── config.go ├── domain │ └── domain.go ├── server │ ├── http.go │ └── http_test.go └── sqlstore │ ├── sql.go │ └── sql_test.go └── main.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .goreleaser.yml 2 | .github/ 3 | dist/ 4 | docker-compose.yml 5 | CHANGELOG.md 6 | README.md 7 | LICENSE 8 | Makefile 9 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: '1.23' 23 | 24 | - name: Lint 25 | uses: golangci/golangci-lint-action@v6 26 | with: 27 | version: 'v1.60' 28 | 29 | - name: Test 30 | run: go test -v ./... 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # os 2 | .DS_Store 3 | Thumbs.db 4 | Desktop.ini 5 | 6 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 7 | *.o 8 | *.a 9 | *.so 10 | 11 | # Folders 12 | _obj 13 | _test 14 | 15 | # Architecture specific extensions/prefixes 16 | *.[568vq] 17 | [568vq].out 18 | 19 | *.cgo1.go 20 | *.cgo2.c 21 | _cgo_defun.c 22 | _cgo_gotypes.go 23 | _cgo_export.* 24 | 25 | _testmain.go 26 | 27 | *.exe 28 | *.test 29 | *.prof 30 | *.out 31 | 32 | # gogland 33 | .idea/ 34 | 35 | # goreleaser dist 36 | dist/ 37 | 38 | # binary 39 | pgbouncer_exporter -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: pgbouncer_exporter 3 | release: 4 | github: 5 | owner: jbub 6 | name: pgbouncer_exporter 7 | builds: 8 | - main: main.go 9 | binary: pgbouncer_exporter 10 | ldflags: | 11 | -s 12 | -w 13 | -X github.com/prometheus/common/version.Version={{ .Version }} 14 | -X github.com/prometheus/common/version.Revision={{ .Commit }} 15 | -X github.com/prometheus/common/version.BuildDate={{ .Date }} 16 | -extldflags '-static' 17 | flags: -tags netgo 18 | env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - darwin 22 | - linux 23 | - windows 24 | goarch: 25 | - amd64 26 | - arm 27 | - arm64 28 | - 386 29 | archives: 30 | - id: release 31 | format: tar.gz 32 | format_overrides: 33 | - goos: windows 34 | format: zip 35 | name_template: "{{ .Binary }}_{{.Version}}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{.Arm }}{{ end }}" 36 | files: 37 | - LICENSE 38 | - README.md 39 | - CHANGELOG.md 40 | snapshot: 41 | version_template: "{{ .Commit }}" 42 | dockers: 43 | - image_templates: 44 | - "jbub/pgbouncer_exporter:{{ .Tag }}" 45 | - "jbub/pgbouncer_exporter:latest" 46 | dockerfile: .goreleaser/Dockerfile 47 | use: buildx 48 | build_flag_templates: 49 | - "--pull" 50 | - "--label=org.opencontainers.image.created={{.Date}}" 51 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 52 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 53 | - "--label=org.opencontainers.image.version={{.Version}}" 54 | - "--label=org.opencontainers.image.source={{.GitURL}}" 55 | - "--platform=linux/amd64" 56 | - image_templates: 57 | - "jbub/pgbouncer_exporter:{{ .Tag }}-arm64" 58 | - "jbub/pgbouncer_exporter:latest-arm64" 59 | dockerfile: .goreleaser/Dockerfile 60 | use: buildx 61 | build_flag_templates: 62 | - "--pull" 63 | - "--label=org.opencontainers.image.created={{.Date}}" 64 | - "--label=org.opencontainers.image.name={{.ProjectName}}" 65 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 66 | - "--label=org.opencontainers.image.version={{.Version}}" 67 | - "--label=org.opencontainers.image.source={{.GitURL}}" 68 | - "--platform=linux/arm64" 69 | goarch: arm64 70 | docker_manifests: 71 | - name_template: 'jbub/pgbouncer_exporter:{{ .Tag }}' 72 | image_templates: 73 | - 'jbub/pgbouncer_exporter:{{ .Tag }}' 74 | - 'jbub/pgbouncer_exporter:{{ .Tag }}-arm64' 75 | -------------------------------------------------------------------------------- /.goreleaser/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | LABEL maintainer="Juraj Bubniak " 3 | 4 | RUN addgroup -S pgbouncer_exporter \ 5 | && adduser -D -S -s /sbin/nologin -G pgbouncer_exporter pgbouncer_exporter 6 | 7 | RUN apk --no-cache add tzdata ca-certificates 8 | 9 | COPY pgbouncer_exporter /bin 10 | 11 | USER pgbouncer_exporter 12 | 13 | HEALTHCHECK CMD ["pgbouncer_exporter", "health"] 14 | 15 | ENTRYPOINT ["pgbouncer_exporter"] 16 | CMD ["server"] 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.19.0 2 | 3 | * Add support for PgBouncer 1.24. 4 | * Use alpine:3.21 as a base Docker image. 5 | * Add multi-stage Docker build. 6 | 7 | ## 0.18.0 8 | 9 | * Build with Go 1.23. 10 | * Use alpine:3.20 as a base Docker image. 11 | * Add server_lifetime metric. 12 | * Use PgBouncer 1.23 in docker compose. 13 | * Update dependencies. 14 | 15 | ## 0.17.0 16 | 17 | * Build with Go 1.22. 18 | * Use alpine:3.19 as a base Docker image. 19 | * Add pool_size and max_connections metrics. 20 | * Update dependencies. 21 | * Update docker-compose.yml versions. 22 | 23 | ## 0.16.0 24 | 25 | * Build with Go 1.21. 26 | * Use alpine:3.18 as a base Docker image. 27 | * Update github.com/prometheus/client_golang to v1.17.0. 28 | 29 | ## 0.15.0 30 | 31 | * Build with Go 1.20. 32 | * Use alpine:3.17 as a base Docker image. 33 | * Add support for PgBouncer 1.18 and make it minimum supported version. 34 | * Use Github actions instead of Drone. 35 | * Update dependencies. 36 | 37 | ## 0.14.0 38 | 39 | * Build with Go 1.19. 40 | * Use alpine:3.15 as a base Docker image. 41 | * Update dependencies. 42 | * Update docker-compose.yml. 43 | 44 | ## 0.13.0 45 | 46 | * Update Dockerfile to not run as nonroot. 47 | 48 | ## 0.12.0 49 | 50 | * Build with Go 1.17. 51 | * Use alpine:3.14 as a base Docker image. 52 | 53 | ## 0.11.0 54 | 55 | * Add support for PgBouncer 1.16. 56 | * Update to github.com/prometheus/common v0.30.0. 57 | 58 | ## 0.10.0 59 | 60 | * Drop sqlx, use stdlib database. 61 | * Add Makefile. 62 | 63 | ## 0.9.2 64 | 65 | * Fix docker image templates. 66 | 67 | ## 0.9.1 68 | 69 | * Use multiarch docker build to support both amd64 and arm64 platforms. 70 | 71 | ## 0.9.0 72 | 73 | * Build with Go 1.16. 74 | * Build also arm64 goarch. 75 | * Bump packages. 76 | 77 | ## 0.8.0 78 | 79 | * Use alpine:3.12 as a base Docker image. 80 | 81 | ## 0.7.0 82 | 83 | * Add support for default constant prometheus labels. 84 | * Bump github.com/prometheus/client_golang to v1.8.0. 85 | 86 | ## 0.6.0 87 | 88 | * Refactor exporter to use NewConstMetric. 89 | * Build with Go 1.15. 90 | * Bump dependencies. 91 | 92 | ## 0.5.0 93 | 94 | * Build with Go 1.13. 95 | * Use sqlx.Open instead of sqlx.Connect to skip calling Ping. 96 | * Use custom query in store.Check. 97 | * Check store on startup. 98 | * Add docker compose for testing. 99 | * Update to github.com/urfave/cli/v2. 100 | * Bump github.com/prometheus/client_golang to v1.3.0. 101 | * Bump github.com/lib/pq to v1.3.0. 102 | * Update goreleaser config to support latest version. 103 | 104 | ## 0.4.0 105 | 106 | * Build with Go 1.12. 107 | * Pin Go modules to version tags. 108 | * Move code to internal package. 109 | * Switch ci from travis to drone. 110 | 111 | ## 0.3.1 112 | 113 | * Fix build version passing in .goreleaser.yml. 114 | 115 | ## 0.3.0 116 | 117 | * Export more metrics from stats and pools. 118 | * Build with Go 1.11.2. 119 | * Add Go modules support. 120 | * Drop dep support. 121 | 122 | ## 0.2.2 123 | 124 | * Update vendored libs, prune tests and unused pkgs. 125 | * Build with Go 1.10.3. 126 | * Add golangci.yml. 127 | 128 | ## 0.2.1 129 | 130 | * Build with Go 1.9.4. 131 | 132 | ## 0.2.0 133 | 134 | * Add support for PgBouncer 1.8. 135 | 136 | ## 0.1.5 137 | 138 | * Build with Go 1.9.2. 139 | * Add Docker setup to Goreleaser config. 140 | 141 | ## 0.1.4 142 | 143 | * Add healthcheck. 144 | 145 | ## 0.1.3 146 | 147 | * Refactor http server to improve testability. 148 | 149 | ## 0.1.2 150 | 151 | * Fill missing Active field in sql store GetPools method. 152 | 153 | ## 0.1.1 154 | 155 | * Make database column ForceUser nullable.. 156 | 157 | ## 0.1.0 158 | 159 | * Initial release. 160 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS builder 2 | COPY . . 3 | RUN CGO_ENABLED=0 go build -ldflags "-extldflags '-static'" -tags netgo -o /bin/pgbouncer_exporter 4 | 5 | FROM alpine:3.21 6 | LABEL maintainer="Juraj Bubniak " 7 | 8 | RUN addgroup -S pgbouncer_exporter \ 9 | && adduser -D -S -s /sbin/nologin -G pgbouncer_exporter pgbouncer_exporter 10 | 11 | RUN apk --no-cache add tzdata ca-certificates 12 | 13 | COPY --from=builder /bin/pgbouncer_exporter /bin 14 | 15 | USER pgbouncer_exporter 16 | 17 | HEALTHCHECK CMD ["pgbouncer_exporter", "health"] 18 | 19 | ENTRYPOINT ["pgbouncer_exporter"] 20 | CMD ["server"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Juraj Bubniak 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 | .PHONY: help build build_linux lint test race cover coverhtml 2 | 3 | help: 4 | @echo "Please use 'make ' where is one of" 5 | @echo " lint to run golint on files recursively" 6 | @echo " build to build binary" 7 | @echo " test to run tests" 8 | @echo " race to run tests with race detector" 9 | @echo " cover to run tests with coverage" 10 | @echo " coverhtml to run tests with coverage and generate html output" 11 | 12 | lint: 13 | golangci-lint -v run 14 | 15 | build: 16 | CGO_ENABLED=0 go build -ldflags "-extldflags '-static'" -tags netgo -o pgbouncer_exporter 17 | 18 | build_linux: 19 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-extldflags '-static'" -tags netgo -o pgbouncer_exporter 20 | 21 | test: 22 | go test -v ./... 23 | 24 | race: 25 | go test -race -v ./... 26 | 27 | cover: 28 | go test -v -coverprofile=coverage.out -cover ./... 29 | 30 | coverhtml: 31 | go test -v -coverprofile=coverage.out -cover ./... 32 | go tool cover -html=coverage.out -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pgbouncer exporter 2 | [![Build Status](https://github.com/jbub/pgbouncer_exporter/actions/workflows/go.yml/badge.svg)][build] 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/jbub/pgbouncer_exporter.svg?maxAge=604800)][hub] 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/jbub/pgbouncer_exporter)][goreportcard] 5 | 6 | Prometheus exporter for Pgbouncer metrics. The minimum supported version of Pgbouncer is 1.18. 7 | 8 | ## Docker 9 | 10 | Metrics are by default exposed on http server running on port `9127` under the `/metrics` path. 11 | 12 | ```bash 13 | docker run \ 14 | --detach \ 15 | --env "DATABASE_URL=postgres://user:password@pgbouncer:6432/pgbouncer?sslmode=disable" \ 16 | --publish "9127:9127" \ 17 | --name "pgbouncer_exporter" \ 18 | jbub/pgbouncer_exporter 19 | ``` 20 | 21 | ## Collectors 22 | 23 | All of the collectors are enabled by default, you can control that using environment variables by settings 24 | it to `true` or `false`. 25 | 26 | | Name | Description | Env var | Default | 27 | |---------------|-----------------------------------------|------------------|---------| 28 | | stats | Per database requests stats. | EXPORT_STATS | Enabled | 29 | | pools | Per (database, user) connection stats. | EXPORT_POOLS | Enabled | 30 | | databases | List of configured databases. | EXPORT_DATABASES | Enabled | 31 | | lists | List of internal pgbouncer information. | EXPORT_LISTS | Enabled | 32 | 33 | ## Default constant prometheus labels 34 | 35 | In order to provide default prometheus constant labels you can use the `DEFAULT_LABELS` enviroment variable. 36 | Labels can be set in this format `instance=pg1 env=dev`. Provided labels will be added to all the metrics. 37 | 38 | [build]: https://github.com/jbub/pgbouncer_exporter/actions/workflows/go.yml 39 | [hub]: https://hub.docker.com/r/jbub/pgbouncer_exporter 40 | [goreportcard]: https://goreportcard.com/report/github.com/jbub/pgbouncer_exporter 41 | -------------------------------------------------------------------------------- /cmd/health.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/jbub/pgbouncer_exporter/internal/config" 9 | "github.com/jbub/pgbouncer_exporter/internal/sqlstore" 10 | 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | // Health is a cli command used for checking the health of the system. 15 | var Health = &cli.Command{ 16 | Name: "health", 17 | Usage: "Checks the health of the system.", 18 | Action: checkHealth, 19 | } 20 | 21 | func checkHealth(ctx *cli.Context) error { 22 | cfg := config.LoadFromCLI(ctx) 23 | 24 | db, err := sql.Open("postgres", cfg.DatabaseURL) 25 | if err != nil { 26 | return fmt.Errorf("could not open db: %v", err) 27 | } 28 | defer db.Close() 29 | 30 | store := sqlstore.New(db) 31 | 32 | checkCtx, cancel := context.WithTimeout(context.Background(), cfg.StoreTimeout) 33 | defer cancel() 34 | 35 | if err := store.Check(checkCtx); err != nil { 36 | return fmt.Errorf("store health check failed: %v", err) 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /cmd/server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log" 8 | 9 | "github.com/jbub/pgbouncer_exporter/internal/collector" 10 | "github.com/jbub/pgbouncer_exporter/internal/config" 11 | "github.com/jbub/pgbouncer_exporter/internal/server" 12 | "github.com/jbub/pgbouncer_exporter/internal/sqlstore" 13 | 14 | "github.com/prometheus/common/version" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | // Server is a cli command used for running exporter http server. 19 | var Server = &cli.Command{ 20 | Name: "server", 21 | Usage: "Starts exporter server.", 22 | Action: runServer, 23 | } 24 | 25 | func runServer(ctx *cli.Context) error { 26 | cfg := config.LoadFromCLI(ctx) 27 | 28 | db, err := sql.Open("postgres", cfg.DatabaseURL) 29 | if err != nil { 30 | return fmt.Errorf("could not open db: %v", err) 31 | } 32 | defer db.Close() 33 | 34 | store := sqlstore.New(db) 35 | 36 | checkCtx, cancel := context.WithTimeout(context.Background(), cfg.StoreTimeout) 37 | defer cancel() 38 | 39 | if err := store.Check(checkCtx); err != nil { 40 | return fmt.Errorf("could not check store: %v", err) 41 | } 42 | 43 | exp := collector.New(cfg, store) 44 | srv := server.New(cfg, exp) 45 | 46 | log.Println("Starting ", collector.Name, version.Info()) 47 | log.Println("Server listening on", cfg.ListenAddress) 48 | log.Println("Metrics available at", cfg.TelemetryPath) 49 | log.Println("Build context", version.BuildContext()) 50 | 51 | if err := srv.Run(); err != nil { 52 | return fmt.Errorf("could not run server: %v", err) 53 | } 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: "postgres:15-alpine" 4 | restart: always 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | POSTGRES_USER: "postgres" 9 | POSTGRES_PASSWORD: "postgres" 10 | 11 | pgbouncer: 12 | image: "bitnami/pgbouncer:1.24.0" 13 | restart: always 14 | ports: 15 | - "6432:6432" 16 | environment: 17 | POSTGRESQL_HOST: "postgres" 18 | POSTGRESQL_USERNAME: "postgres" 19 | POSTGRESQL_PASSWORD: "postgres" 20 | PGBOUNCER_AUTH_TYPE: "trust" 21 | PGBOUNCER_IGNORE_STARTUP_PARAMETERS: "extra_float_digits" 22 | depends_on: 23 | - postgres 24 | 25 | pgbouncer_exporter: 26 | build: 27 | context: . 28 | restart: always 29 | ports: 30 | - "9127:9127" 31 | environment: 32 | DATABASE_URL: "postgres://postgres:postgres@pgbouncer:6432/pgbouncer?sslmode=disable" 33 | DEFAULT_LABELS: "instance=pg1 env=dev" 34 | depends_on: 35 | - pgbouncer 36 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jbub/pgbouncer_exporter 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/DATA-DOG/go-sqlmock v1.5.2 7 | github.com/lib/pq v1.10.9 8 | github.com/prometheus/client_golang v1.20.4 9 | github.com/prometheus/common v0.60.0 10 | github.com/stretchr/testify v1.9.0 11 | github.com/urfave/cli/v2 v2.27.4 12 | ) 13 | 14 | require ( 15 | github.com/beorn7/perks v1.0.1 // indirect 16 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 17 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 18 | github.com/davecgh/go-spew v1.1.1 // indirect 19 | github.com/klauspost/compress v1.17.9 // indirect 20 | github.com/kr/text v0.2.0 // indirect 21 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 22 | github.com/pmezard/go-difflib v1.0.0 // indirect 23 | github.com/prometheus/client_model v0.6.1 // indirect 24 | github.com/prometheus/procfs v0.15.1 // indirect 25 | github.com/rogpeppe/go-internal v1.11.0 // indirect 26 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 27 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 28 | golang.org/x/sys v0.25.0 // indirect 29 | google.golang.org/protobuf v1.34.2 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 2 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 13 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= 15 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 16 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 17 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 18 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 19 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 20 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 21 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 22 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 23 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 24 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 25 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 26 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= 30 | github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 31 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 32 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 33 | github.com/prometheus/common v0.60.0 h1:+V9PAREWNvJMAuJ1x1BaWl9dewMW4YrHZQbx0sJNllA= 34 | github.com/prometheus/common v0.60.0/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= 35 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 36 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 37 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 38 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 39 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 40 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 41 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 42 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 43 | github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= 44 | github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= 45 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 46 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 47 | golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= 48 | golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 49 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 50 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 53 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 54 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 55 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | -------------------------------------------------------------------------------- /internal/collector/exporter.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/jbub/pgbouncer_exporter/internal/config" 11 | "github.com/jbub/pgbouncer_exporter/internal/domain" 12 | 13 | "github.com/prometheus/client_golang/prometheus" 14 | ) 15 | 16 | const ( 17 | // Name is the name of the exporter. 18 | Name = "pgbouncer_exporter" 19 | ) 20 | 21 | // Names of the exporter subsystems. 22 | const ( 23 | SubsystemStats = "stats" 24 | SubsystemPools = "pools" 25 | SubsystemDatabases = "database" 26 | SubsystemLists = "lists" 27 | ) 28 | 29 | var ( 30 | _ prometheus.Collector = &Exporter{} 31 | ) 32 | 33 | type metric struct { 34 | enabled bool 35 | name string 36 | help string 37 | labels []string 38 | valType prometheus.ValueType 39 | eval func(res *storeResult) []metricResult 40 | } 41 | 42 | func (m metric) desc(constLabels prometheus.Labels) *prometheus.Desc { 43 | return prometheus.NewDesc(m.name, m.help, m.labels, constLabels) 44 | } 45 | 46 | type metricResult struct { 47 | labels []string 48 | value float64 49 | } 50 | 51 | type storeResult struct { 52 | stats []domain.Stat 53 | pools []domain.Pool 54 | databases []domain.Database 55 | lists []domain.List 56 | } 57 | 58 | // Exporter represents pgbouncer prometheus stats exporter. 59 | type Exporter struct { 60 | cfg config.Config 61 | stor domain.Store 62 | mut sync.Mutex // guards Collect 63 | constLabels prometheus.Labels 64 | metrics []metric 65 | } 66 | 67 | // New returns new Exporter. 68 | func New(cfg config.Config, stor domain.Store) *Exporter { 69 | return &Exporter{ 70 | stor: stor, 71 | cfg: cfg, 72 | constLabels: parseLabels(cfg.DefaultLabels), 73 | metrics: buildMetrics(cfg), 74 | } 75 | } 76 | 77 | // Describe implements prometheus Collector.Describe. 78 | func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { 79 | for _, met := range e.metrics { 80 | if !met.enabled { 81 | continue 82 | } 83 | ch <- met.desc(e.constLabels) 84 | } 85 | } 86 | 87 | // Collect implements prometheus Collector.Collect. 88 | func (e *Exporter) Collect(ch chan<- prometheus.Metric) { 89 | e.mut.Lock() 90 | defer e.mut.Unlock() 91 | 92 | ctx, cancel := context.WithTimeout(context.Background(), e.cfg.StoreTimeout) 93 | defer cancel() 94 | 95 | res, err := e.getStoreResult(ctx) 96 | if err != nil { 97 | log.Printf("could not get store result: %v", err) 98 | return 99 | } 100 | 101 | for _, met := range e.metrics { 102 | if !met.enabled { 103 | continue 104 | } 105 | 106 | results := met.eval(res) 107 | 108 | for _, res := range results { 109 | ch <- prometheus.MustNewConstMetric( 110 | met.desc(e.constLabels), 111 | met.valType, 112 | res.value, 113 | res.labels..., 114 | ) 115 | } 116 | } 117 | } 118 | 119 | func (e *Exporter) getStoreResult(ctx context.Context) (*storeResult, error) { 120 | res := new(storeResult) 121 | 122 | if e.cfg.ExportStats { 123 | stats, err := e.stor.GetStats(ctx) 124 | if err != nil { 125 | return nil, fmt.Errorf("could not get stats: %v", err) 126 | } 127 | res.stats = stats 128 | } 129 | 130 | if e.cfg.ExportPools { 131 | pools, err := e.stor.GetPools(ctx) 132 | if err != nil { 133 | return nil, fmt.Errorf("could not get pools: %v", err) 134 | } 135 | res.pools = pools 136 | } 137 | 138 | if e.cfg.ExportDatabases { 139 | databases, err := e.stor.GetDatabases(ctx) 140 | if err != nil { 141 | return nil, fmt.Errorf("could not get databases: %v", err) 142 | } 143 | res.databases = databases 144 | } 145 | 146 | if e.cfg.ExportLists { 147 | lists, err := e.stor.GetLists(ctx) 148 | if err != nil { 149 | return nil, fmt.Errorf("could not get lists: %v", err) 150 | } 151 | res.lists = lists 152 | } 153 | 154 | return res, nil 155 | } 156 | 157 | func parseLabels(s string) prometheus.Labels { 158 | if s == "" { 159 | return nil 160 | } 161 | 162 | items := strings.Split(s, " ") 163 | res := make(prometheus.Labels, len(items)) 164 | for _, item := range items { 165 | if item == "" { 166 | continue 167 | } 168 | if parts := strings.SplitN(item, "=", 2); len(parts) == 2 { 169 | res[parts[0]] = parts[1] 170 | } 171 | } 172 | return res 173 | } 174 | -------------------------------------------------------------------------------- /internal/collector/exporter_test.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/jbub/pgbouncer_exporter/internal/config" 8 | "github.com/jbub/pgbouncer_exporter/internal/sqlstore" 9 | 10 | "github.com/DATA-DOG/go-sqlmock" 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestGetStoreResultExportEnabled(t *testing.T) { 16 | db, mock, err := sqlmock.New() 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | defer db.Close() 21 | 22 | cfg := config.Config{ 23 | ExportStats: true, 24 | ExportPools: true, 25 | ExportDatabases: true, 26 | ExportLists: true, 27 | } 28 | 29 | exp := New(cfg, sqlstore.New(db)) 30 | ctx := context.Background() 31 | 32 | mock.ExpectQuery("SHOW STATS").WillReturnRows(sqlmock.NewRows(nil)) 33 | mock.ExpectQuery("SHOW POOLS").WillReturnRows(sqlmock.NewRows(nil)) 34 | mock.ExpectQuery("SHOW DATABASES").WillReturnRows(sqlmock.NewRows(nil)) 35 | mock.ExpectQuery("SHOW LISTS").WillReturnRows(sqlmock.NewRows(nil)) 36 | 37 | _, err = exp.getStoreResult(ctx) 38 | require.NoError(t, err) 39 | require.NoError(t, mock.ExpectationsWereMet()) 40 | } 41 | 42 | func TestGetStoreResultExportDisabled(t *testing.T) { 43 | db, mock, err := sqlmock.New() 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | defer db.Close() 48 | 49 | cfg := config.Config{ 50 | ExportStats: false, 51 | ExportPools: false, 52 | ExportDatabases: false, 53 | ExportLists: false, 54 | } 55 | 56 | exp := New(cfg, sqlstore.New(db)) 57 | ctx := context.Background() 58 | 59 | _, err = exp.getStoreResult(ctx) 60 | require.NoError(t, err) 61 | require.NoError(t, mock.ExpectationsWereMet()) 62 | } 63 | 64 | var ( 65 | parseLabelsCases = []struct { 66 | name string 67 | value string 68 | expected prometheus.Labels 69 | }{ 70 | { 71 | name: "empty", 72 | value: "", 73 | expected: nil, 74 | }, 75 | { 76 | name: "invalid item", 77 | value: "key", 78 | expected: prometheus.Labels{}, 79 | }, 80 | { 81 | name: "blank item", 82 | value: "key=", 83 | expected: prometheus.Labels{ 84 | "key": "", 85 | }, 86 | }, 87 | { 88 | name: "single item", 89 | value: "key=value", 90 | expected: prometheus.Labels{ 91 | "key": "value", 92 | }, 93 | }, 94 | { 95 | name: "multiple items", 96 | value: "key=value key2=value2", 97 | expected: prometheus.Labels{ 98 | "key": "value", 99 | "key2": "value2", 100 | }, 101 | }, 102 | { 103 | name: "multiple items with space", 104 | value: "key=value key2=value2 ", 105 | expected: prometheus.Labels{ 106 | "key": "value", 107 | "key2": "value2", 108 | }, 109 | }, 110 | } 111 | ) 112 | 113 | func TestParseLabels(t *testing.T) { 114 | for _, cs := range parseLabelsCases { 115 | t.Run(cs.name, func(t *testing.T) { 116 | labels := parseLabels(cs.value) 117 | require.Equal(t, cs.expected, labels) 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /internal/collector/metrics.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "github.com/jbub/pgbouncer_exporter/internal/config" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | ) 8 | 9 | func buildMetrics(cfg config.Config) []metric { 10 | return []metric{ 11 | { 12 | enabled: cfg.ExportStats, 13 | name: fqName(SubsystemStats, "total_received"), 14 | help: "Total volume in bytes of network traffic received by pgbouncer.", 15 | labels: []string{"database"}, 16 | valType: prometheus.GaugeValue, 17 | eval: func(res *storeResult) (results []metricResult) { 18 | for _, stat := range res.stats { 19 | results = append(results, metricResult{ 20 | labels: []string{stat.Database}, 21 | value: float64(stat.TotalReceived), 22 | }) 23 | } 24 | return results 25 | }, 26 | }, 27 | { 28 | enabled: cfg.ExportStats, 29 | name: fqName(SubsystemStats, "total_sent"), 30 | help: "Total volume in bytes of network traffic sent by pgbouncer.", 31 | labels: []string{"database"}, 32 | valType: prometheus.GaugeValue, 33 | eval: func(res *storeResult) (results []metricResult) { 34 | for _, stat := range res.stats { 35 | results = append(results, metricResult{ 36 | labels: []string{stat.Database}, 37 | value: float64(stat.TotalSent), 38 | }) 39 | } 40 | return results 41 | }, 42 | }, 43 | { 44 | enabled: cfg.ExportStats, 45 | name: fqName(SubsystemStats, "total_query_time"), 46 | help: "Total number of microseconds spent by pgbouncer when actively connected to PostgreSQL.", 47 | labels: []string{"database"}, 48 | valType: prometheus.GaugeValue, 49 | eval: func(res *storeResult) (results []metricResult) { 50 | for _, stat := range res.stats { 51 | results = append(results, metricResult{ 52 | labels: []string{stat.Database}, 53 | value: float64(stat.TotalQueryTime), 54 | }) 55 | } 56 | return results 57 | }, 58 | }, 59 | { 60 | enabled: cfg.ExportStats, 61 | name: fqName(SubsystemStats, "total_xact_time"), 62 | help: "Total number of microseconds spent by pgbouncer when connected to PostgreSQL in a transaction, either idle in transaction or executing queries.", 63 | labels: []string{"database"}, 64 | valType: prometheus.GaugeValue, 65 | eval: func(res *storeResult) (results []metricResult) { 66 | for _, stat := range res.stats { 67 | results = append(results, metricResult{ 68 | labels: []string{stat.Database}, 69 | value: float64(stat.TotalXactTime), 70 | }) 71 | } 72 | return results 73 | }, 74 | }, 75 | { 76 | enabled: cfg.ExportStats, 77 | name: fqName(SubsystemStats, "total_query_count"), 78 | help: "Total number of SQL queries pooled by pgbouncer.", 79 | labels: []string{"database"}, 80 | valType: prometheus.GaugeValue, 81 | eval: func(res *storeResult) (results []metricResult) { 82 | for _, stat := range res.stats { 83 | results = append(results, metricResult{ 84 | labels: []string{stat.Database}, 85 | value: float64(stat.TotalQueryCount), 86 | }) 87 | } 88 | return results 89 | }, 90 | }, 91 | { 92 | enabled: cfg.ExportStats, 93 | name: fqName(SubsystemStats, "total_xact_count"), 94 | help: "Total number of SQL transactions pooled by pgbouncer.", 95 | labels: []string{"database"}, 96 | valType: prometheus.GaugeValue, 97 | eval: func(res *storeResult) (results []metricResult) { 98 | for _, stat := range res.stats { 99 | results = append(results, metricResult{ 100 | labels: []string{stat.Database}, 101 | value: float64(stat.TotalXactCount), 102 | }) 103 | } 104 | return results 105 | }, 106 | }, 107 | { 108 | enabled: cfg.ExportPools, 109 | name: fqName(SubsystemPools, "active_clients"), 110 | help: "Client connections that are linked to server connection and can process queries.", 111 | labels: []string{"database", "user", "pool_mode"}, 112 | valType: prometheus.GaugeValue, 113 | eval: func(res *storeResult) (results []metricResult) { 114 | for _, pool := range res.pools { 115 | results = append(results, metricResult{ 116 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 117 | value: float64(pool.Active), 118 | }) 119 | } 120 | return results 121 | }, 122 | }, 123 | { 124 | enabled: cfg.ExportPools, 125 | name: fqName(SubsystemPools, "waiting_clients"), 126 | help: "Client connections have sent queries but have not yet got a server connection.", 127 | labels: []string{"database", "user", "pool_mode"}, 128 | valType: prometheus.GaugeValue, 129 | eval: func(res *storeResult) (results []metricResult) { 130 | for _, pool := range res.pools { 131 | results = append(results, metricResult{ 132 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 133 | value: float64(pool.Waiting), 134 | }) 135 | } 136 | return results 137 | }, 138 | }, 139 | { 140 | enabled: cfg.ExportPools, 141 | name: fqName(SubsystemPools, "active_server"), 142 | help: "Server connections that are linked to a client.", 143 | labels: []string{"database", "user", "pool_mode"}, 144 | valType: prometheus.GaugeValue, 145 | eval: func(res *storeResult) (results []metricResult) { 146 | for _, pool := range res.pools { 147 | results = append(results, metricResult{ 148 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 149 | value: float64(pool.ServerActive), 150 | }) 151 | } 152 | return results 153 | }, 154 | }, 155 | { 156 | enabled: cfg.ExportPools, 157 | name: fqName(SubsystemPools, "idle_server"), 158 | help: "Server connections that are unused and immediately usable for client queries.", 159 | labels: []string{"database", "user", "pool_mode"}, 160 | valType: prometheus.GaugeValue, 161 | eval: func(res *storeResult) (results []metricResult) { 162 | for _, pool := range res.pools { 163 | results = append(results, metricResult{ 164 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 165 | value: float64(pool.ServerIdle), 166 | }) 167 | } 168 | return results 169 | }, 170 | }, 171 | { 172 | enabled: cfg.ExportPools, 173 | name: fqName(SubsystemPools, "used_server"), 174 | help: "Server connections that have been idle for more than server_check_delay, so they need server_check_query to run on them before they can be used again.", 175 | labels: []string{"database", "user", "pool_mode"}, 176 | valType: prometheus.GaugeValue, 177 | eval: func(res *storeResult) (results []metricResult) { 178 | for _, pool := range res.pools { 179 | results = append(results, metricResult{ 180 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 181 | value: float64(pool.ServerUsed), 182 | }) 183 | } 184 | return results 185 | }, 186 | }, 187 | { 188 | enabled: cfg.ExportPools, 189 | name: fqName(SubsystemPools, "tested_server"), 190 | help: "Server connections that are currently running either server_reset_query or server_check_query.", 191 | labels: []string{"database", "user", "pool_mode"}, 192 | valType: prometheus.GaugeValue, 193 | eval: func(res *storeResult) (results []metricResult) { 194 | for _, pool := range res.pools { 195 | results = append(results, metricResult{ 196 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 197 | value: float64(pool.ServerTested), 198 | }) 199 | } 200 | return results 201 | }, 202 | }, 203 | { 204 | enabled: cfg.ExportPools, 205 | name: fqName(SubsystemPools, "login_server"), 206 | help: "Server connections currently in the process of logging in.", 207 | labels: []string{"database", "user", "pool_mode"}, 208 | valType: prometheus.GaugeValue, 209 | eval: func(res *storeResult) (results []metricResult) { 210 | for _, pool := range res.pools { 211 | results = append(results, metricResult{ 212 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 213 | value: float64(pool.ServerLogin), 214 | }) 215 | } 216 | return results 217 | }, 218 | }, 219 | { 220 | enabled: cfg.ExportPools, 221 | name: fqName(SubsystemPools, "max_wait"), 222 | help: "How long the first (oldest) client in the queue has waited, in seconds. If this starts increasing, then the current pool of servers does not handle requests quickly enough. The reason may be either an overloaded server or just too small of a pool_size setting.", 223 | labels: []string{"database", "user", "pool_mode"}, 224 | valType: prometheus.GaugeValue, 225 | eval: func(res *storeResult) (results []metricResult) { 226 | for _, pool := range res.pools { 227 | results = append(results, metricResult{ 228 | labels: []string{pool.Database, pool.User, pool.PoolMode}, 229 | value: float64(pool.MaxWait), 230 | }) 231 | } 232 | return results 233 | }, 234 | }, 235 | { 236 | enabled: cfg.ExportDatabases, 237 | name: fqName(SubsystemDatabases, "pool_size"), 238 | help: "Maximum number of server connections.", 239 | labels: []string{"name", "pool_mode"}, 240 | valType: prometheus.GaugeValue, 241 | eval: func(res *storeResult) (results []metricResult) { 242 | for _, database := range res.databases { 243 | results = append(results, metricResult{ 244 | labels: []string{database.Name, database.PoolMode}, 245 | value: float64(database.PoolSize), 246 | }) 247 | } 248 | return results 249 | }, 250 | }, 251 | { 252 | enabled: cfg.ExportDatabases, 253 | name: fqName(SubsystemDatabases, "current_connections"), 254 | help: "Current number of connections for this database.", 255 | labels: []string{"name", "pool_mode"}, 256 | valType: prometheus.GaugeValue, 257 | eval: func(res *storeResult) (results []metricResult) { 258 | for _, database := range res.databases { 259 | results = append(results, metricResult{ 260 | labels: []string{database.Name, database.PoolMode}, 261 | value: float64(database.CurrentConnections), 262 | }) 263 | } 264 | return results 265 | }, 266 | }, 267 | { 268 | enabled: cfg.ExportDatabases, 269 | name: fqName(SubsystemDatabases, "max_connections"), 270 | help: "Maximum number of allowed connections for this database.", 271 | labels: []string{"name", "pool_mode"}, 272 | valType: prometheus.GaugeValue, 273 | eval: func(res *storeResult) (results []metricResult) { 274 | for _, database := range res.databases { 275 | results = append(results, metricResult{ 276 | labels: []string{database.Name, database.PoolMode}, 277 | value: float64(database.MaxConnections), 278 | }) 279 | } 280 | return results 281 | }, 282 | }, 283 | { 284 | enabled: cfg.ExportDatabases, 285 | name: fqName(SubsystemDatabases, "server_lifetime"), 286 | help: "The maximum lifetime of a server connection for this database.", 287 | labels: []string{"name", "pool_mode"}, 288 | valType: prometheus.GaugeValue, 289 | eval: func(res *storeResult) (results []metricResult) { 290 | for _, database := range res.databases { 291 | results = append(results, metricResult{ 292 | labels: []string{database.Name, database.PoolMode}, 293 | value: float64(database.ServerLifetime), 294 | }) 295 | } 296 | return results 297 | }, 298 | }, 299 | { 300 | enabled: cfg.ExportLists, 301 | name: fqName(SubsystemLists, "items"), 302 | help: "List of internal pgbouncer information.", 303 | labels: []string{"list"}, 304 | valType: prometheus.GaugeValue, 305 | eval: func(res *storeResult) (results []metricResult) { 306 | for _, list := range res.lists { 307 | results = append(results, metricResult{ 308 | labels: []string{list.List}, 309 | value: float64(list.Items), 310 | }) 311 | } 312 | return results 313 | }, 314 | }, 315 | } 316 | } 317 | 318 | func fqName(subsystem string, name string) string { 319 | return prometheus.BuildFQName(Name, subsystem, name) 320 | } 321 | -------------------------------------------------------------------------------- /internal/collector/registry.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | "github.com/prometheus/client_golang/prometheus/collectors" 6 | "github.com/prometheus/client_golang/prometheus/collectors/version" 7 | ) 8 | 9 | // NewRegistry returns new prometheus registry with registered Exporter and common exporters. 10 | func NewRegistry(exp *Exporter) prometheus.Gatherer { 11 | reg := prometheus.NewRegistry() 12 | reg.MustRegister(version.NewCollector(Name)) 13 | reg.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{ 14 | Namespace: "", 15 | ReportErrors: false, 16 | })) 17 | reg.MustRegister(collectors.NewGoCollector()) 18 | reg.MustRegister(exp) 19 | return reg 20 | } 21 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func LoadFromCLI(ctx *cli.Context) Config { 10 | return Config{ 11 | ListenAddress: ctx.String("web.listen-address"), 12 | TelemetryPath: ctx.String("web.telemetry-path"), 13 | DatabaseURL: ctx.String("database-url"), 14 | StoreTimeout: ctx.Duration("store-timeout"), 15 | ExportStats: ctx.Bool("export-stats"), 16 | ExportPools: ctx.Bool("export-pools"), 17 | ExportDatabases: ctx.Bool("export-databases"), 18 | ExportLists: ctx.Bool("export-lists"), 19 | DefaultLabels: ctx.String("default-labels"), 20 | } 21 | } 22 | 23 | // Config represents exporter configuration. 24 | type Config struct { 25 | ListenAddress string 26 | TelemetryPath string 27 | DatabaseURL string 28 | StoreTimeout time.Duration 29 | 30 | ExportStats bool 31 | ExportPools bool 32 | ExportDatabases bool 33 | ExportLists bool 34 | DefaultLabels string 35 | } 36 | -------------------------------------------------------------------------------- /internal/domain/domain.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Stat represents stat row. 8 | type Stat struct { 9 | Database string 10 | TotalReceived int64 11 | TotalSent int64 12 | TotalQueryTime int64 13 | TotalXactCount int64 14 | TotalXactTime int64 15 | TotalQueryCount int64 16 | TotalWaitTime int64 17 | TotalServerAssignmentCount int64 18 | TotalClientParseCount int64 19 | TotalServerParseCount int64 20 | TotalBindCount int64 21 | AverageReceived int64 22 | AverageSent int64 23 | AverageQueryCount int64 24 | AverageQueryTime int64 25 | AverageXactTime int64 26 | AverageXactCount int64 27 | AverageWaitTime int64 28 | AverageServerAssignmentCount int64 29 | AverageClientParseCount int64 30 | AverageServerParseCount int64 31 | AverageBindCount int64 32 | } 33 | 34 | // Pool represents pool row. 35 | type Pool struct { 36 | Database string 37 | User string 38 | Active int64 39 | Waiting int64 40 | CancelReq int64 41 | ActiveCancelReq int64 42 | WaitingCancelReq int64 43 | ServerActive int64 44 | ServerActiveCancel int64 45 | ServerBeingCanceled int64 46 | ServerIdle int64 47 | ServerUsed int64 48 | ServerTested int64 49 | ServerLogin int64 50 | MaxWait int64 51 | MaxWaitUs int64 52 | PoolMode string 53 | } 54 | 55 | // Database represents database row. 56 | type Database struct { 57 | Name string 58 | Host string 59 | Port int64 60 | Database string 61 | ForceUser string 62 | PoolSize int64 63 | MinPoolSize int64 64 | ReservePoolSize int64 65 | PoolMode string 66 | MaxConnections int64 67 | CurrentConnections int64 68 | Paused int64 69 | Disabled int64 70 | ServerLifetime int64 71 | } 72 | 73 | // List represents list row. 74 | type List struct { 75 | List string 76 | Items int64 77 | } 78 | 79 | // Store defines interface for accessing pgbouncer stats. 80 | type Store interface { 81 | // GetStats returns stats. 82 | GetStats(ctx context.Context) ([]Stat, error) 83 | 84 | // GetPools returns pools. 85 | GetPools(ctx context.Context) ([]Pool, error) 86 | 87 | // GetDatabases returns databases. 88 | GetDatabases(ctx context.Context) ([]Database, error) 89 | 90 | // GetLists returns lists. 91 | GetLists(ctx context.Context) ([]List, error) 92 | 93 | // Check checks the health of the store. 94 | Check(ctx context.Context) error 95 | } 96 | -------------------------------------------------------------------------------- /internal/server/http.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/jbub/pgbouncer_exporter/internal/collector" 8 | "github.com/jbub/pgbouncer_exporter/internal/config" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | ) 13 | 14 | func getLandingPage(telemetryPath string) []byte { 15 | return []byte(` 16 | 17 | 18 | ` + collector.Name + ` 19 | 20 | 21 |

` + collector.Name + `

22 |

Metrics

23 | 24 | `) 25 | } 26 | 27 | // New returns new prometheus exporter http server. 28 | func New(cfg config.Config, exp *collector.Exporter) *HTTPServer { 29 | reg := collector.NewRegistry(exp) 30 | mux := newHTTPMux(reg, cfg.TelemetryPath) 31 | srv := newHTTPServer(cfg.ListenAddress, mux) 32 | return &HTTPServer{ 33 | srv: srv, 34 | } 35 | } 36 | 37 | func newHTTPServer(listenAddr string, handler http.Handler) *http.Server { 38 | return &http.Server{ 39 | Addr: listenAddr, 40 | Handler: handler, 41 | ReadTimeout: 10 * time.Second, 42 | WriteTimeout: 10 * time.Second, 43 | ReadHeaderTimeout: 10 * time.Second, 44 | IdleTimeout: 10 * time.Second, 45 | } 46 | } 47 | 48 | func newHTTPMux(reg prometheus.Gatherer, telemetryPath string) *http.ServeMux { 49 | mux := http.NewServeMux() 50 | mux.Handle(telemetryPath, promhttp.HandlerFor(reg, promhttp.HandlerOpts{})) 51 | mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { 52 | _, _ = w.Write(getLandingPage(telemetryPath)) 53 | }) 54 | return mux 55 | } 56 | 57 | // HTTPServer represents prometheus exporter http server. 58 | type HTTPServer struct { 59 | srv *http.Server 60 | } 61 | 62 | // Run runs http server. 63 | func (s *HTTPServer) Run() error { 64 | return s.srv.ListenAndServe() 65 | } 66 | -------------------------------------------------------------------------------- /internal/server/http_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/jbub/pgbouncer_exporter/internal/collector" 10 | "github.com/jbub/pgbouncer_exporter/internal/config" 11 | "github.com/jbub/pgbouncer_exporter/internal/domain" 12 | "github.com/jbub/pgbouncer_exporter/internal/sqlstore" 13 | 14 | "github.com/DATA-DOG/go-sqlmock" 15 | "github.com/prometheus/common/expfmt" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | const ( 20 | buildInfoMetric = "pgbouncer_exporter_build_info" 21 | ) 22 | 23 | var ( 24 | testCases = []struct { 25 | name string 26 | exportPools bool 27 | exportDatabases bool 28 | exportStats bool 29 | exportLists bool 30 | metrics []string 31 | }{ 32 | { 33 | name: "stats", 34 | exportStats: true, 35 | metrics: []string{ 36 | buildInfoMetric, 37 | metricName(collector.SubsystemStats, "total_received"), 38 | metricName(collector.SubsystemStats, "total_sent"), 39 | metricName(collector.SubsystemStats, "total_query_time"), 40 | }, 41 | }, 42 | { 43 | name: "pools", 44 | exportPools: true, 45 | metrics: []string{ 46 | buildInfoMetric, 47 | metricName(collector.SubsystemPools, "waiting_clients"), 48 | metricName(collector.SubsystemPools, "active_clients"), 49 | }, 50 | }, 51 | { 52 | name: "databases", 53 | exportDatabases: true, 54 | metrics: []string{ 55 | buildInfoMetric, 56 | metricName(collector.SubsystemDatabases, "current_connections"), 57 | }, 58 | }, 59 | { 60 | name: "lists", 61 | exportLists: true, 62 | metrics: []string{ 63 | buildInfoMetric, 64 | metricName(collector.SubsystemLists, "items"), 65 | }, 66 | }, 67 | } 68 | ) 69 | 70 | func metricName(subsystem string, name string) string { 71 | return fmt.Sprintf("%v_%v_%v", collector.Name, subsystem, name) 72 | } 73 | 74 | func newTestingServer(cfg config.Config, st domain.Store) *httptest.Server { 75 | exp := collector.New(cfg, st) 76 | httpSrv := New(cfg, exp) 77 | return httptest.NewServer(httpSrv.srv.Handler) 78 | } 79 | 80 | func TestResponseContainsMetrics(t *testing.T) { 81 | var parser expfmt.TextParser 82 | 83 | for _, testCase := range testCases { 84 | t.Run(testCase.name, func(t *testing.T) { 85 | cfg := config.Config{ 86 | TelemetryPath: "/metrics", 87 | ExportPools: testCase.exportPools, 88 | ExportDatabases: testCase.exportDatabases, 89 | ExportStats: testCase.exportStats, 90 | ExportLists: testCase.exportLists, 91 | StoreTimeout: time.Millisecond * 200, 92 | } 93 | 94 | db, mock, err := sqlmock.New() 95 | if err != nil { 96 | t.Fatal(err) 97 | } 98 | defer db.Close() 99 | 100 | srv := newTestingServer(cfg, sqlstore.New(db)) 101 | defer srv.Close() 102 | 103 | if cfg.ExportPools { 104 | mock.ExpectQuery("SHOW POOLS").WillReturnRows(sqlmock.NewRows([]string{"database"}).AddRow("mydb")) 105 | } 106 | 107 | if cfg.ExportStats { 108 | mock.ExpectQuery("SHOW STATS").WillReturnRows(sqlmock.NewRows([]string{"database"}).AddRow("mydb")) 109 | } 110 | 111 | if cfg.ExportDatabases { 112 | mock.ExpectQuery("SHOW DATABASES").WillReturnRows(sqlmock.NewRows([]string{"database"}).AddRow("mydb")) 113 | } 114 | 115 | if cfg.ExportLists { 116 | mock.ExpectQuery("SHOW LISTS").WillReturnRows(sqlmock.NewRows([]string{"list"}).AddRow("mylist")) 117 | } 118 | 119 | client := srv.Client() 120 | resp, err := client.Get(srv.URL + cfg.TelemetryPath) 121 | require.NoError(t, err) 122 | defer resp.Body.Close() 123 | 124 | metrics, err := parser.TextToMetricFamilies(resp.Body) 125 | require.NoError(t, err) 126 | 127 | for _, expMetric := range testCase.metrics { 128 | if _, ok := metrics[expMetric]; !ok { 129 | require.FailNow(t, "metric not found", expMetric) 130 | } 131 | } 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /internal/sqlstore/sql.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/jbub/pgbouncer_exporter/internal/domain" 9 | ) 10 | 11 | type pool struct { 12 | Database string 13 | User string 14 | Active int64 15 | Waiting int64 16 | CancelReq int64 17 | ActiveCancelReq int64 18 | WaitingCancelReq int64 19 | ServerActive int64 20 | ServerActiveCancel int64 21 | ServerBeingCanceled int64 22 | ServerIdle int64 23 | ServerUsed int64 24 | ServerTested int64 25 | ServerLogin int64 26 | MaxWait int64 27 | MaxWaitUs int64 28 | PoolMode sql.NullString 29 | LoadBalanceHosts sql.NullString 30 | } 31 | 32 | type database struct { 33 | Name string 34 | Host sql.NullString 35 | Port int64 36 | Database string 37 | ForceUser sql.NullString 38 | PoolSize int64 39 | MinPoolSize int64 40 | ReservePoolSize int64 41 | ServerLifetime int64 42 | PoolMode sql.NullString 43 | MaxConnections int64 44 | CurrentConnections int64 45 | Paused int64 46 | Disabled int64 47 | LoadBalanceHosts sql.NullString 48 | MaxClientConnections int64 49 | CurrentClientConnections int64 50 | } 51 | 52 | // New returns a new SQLStore. 53 | func New(db *sql.DB) *Store { 54 | return &Store{db: db} 55 | } 56 | 57 | // Store is a sql based Store implementation. 58 | type Store struct { 59 | db *sql.DB 60 | } 61 | 62 | // GetStats returns stats. 63 | func (s *Store) GetStats(ctx context.Context) ([]domain.Stat, error) { 64 | rows, err := s.db.QueryContext(ctx, "SHOW STATS") 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer rows.Close() 69 | 70 | columns, err := rows.Columns() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | var row domain.Stat 76 | var stats []domain.Stat 77 | 78 | for rows.Next() { 79 | dest := make([]any, 0, len(columns)) 80 | 81 | for _, column := range columns { 82 | switch column { 83 | case "database": 84 | dest = append(dest, &row.Database) 85 | case "total_server_assignment_count": 86 | dest = append(dest, &row.TotalServerAssignmentCount) 87 | case "total_xact_count": 88 | dest = append(dest, &row.TotalXactCount) 89 | case "total_query_count": 90 | dest = append(dest, &row.TotalQueryCount) 91 | case "total_received": 92 | dest = append(dest, &row.TotalReceived) 93 | case "total_sent": 94 | dest = append(dest, &row.TotalSent) 95 | case "total_xact_time": 96 | dest = append(dest, &row.TotalXactTime) 97 | case "total_query_time": 98 | dest = append(dest, &row.TotalQueryTime) 99 | case "total_wait_time": 100 | dest = append(dest, &row.TotalWaitTime) 101 | case "total_client_parse_count": 102 | dest = append(dest, &row.TotalClientParseCount) 103 | case "total_server_parse_count": 104 | dest = append(dest, &row.TotalServerParseCount) 105 | case "total_bind_count": 106 | dest = append(dest, &row.TotalBindCount) 107 | case "avg_server_assignment_count": 108 | dest = append(dest, &row.AverageServerAssignmentCount) 109 | case "avg_xact_count": 110 | dest = append(dest, &row.AverageXactCount) 111 | case "avg_query_count": 112 | dest = append(dest, &row.AverageQueryCount) 113 | case "avg_recv": 114 | dest = append(dest, &row.AverageReceived) 115 | case "avg_sent": 116 | dest = append(dest, &row.AverageSent) 117 | case "avg_xact_time": 118 | dest = append(dest, &row.AverageXactTime) 119 | case "avg_query_time": 120 | dest = append(dest, &row.AverageQueryTime) 121 | case "avg_wait_time": 122 | dest = append(dest, &row.AverageWaitTime) 123 | case "avg_client_parse_count": 124 | dest = append(dest, &row.AverageClientParseCount) 125 | case "avg_server_parse_count": 126 | dest = append(dest, &row.AverageServerParseCount) 127 | case "avg_bind_count": 128 | dest = append(dest, &row.AverageBindCount) 129 | default: 130 | return nil, fmt.Errorf("unexpected column: %v", column) 131 | } 132 | } 133 | 134 | if err := rows.Scan(dest...); err != nil { 135 | return nil, err 136 | } 137 | stats = append(stats, row) 138 | } 139 | 140 | if err := rows.Err(); err != nil { 141 | return nil, err 142 | } 143 | 144 | return stats, nil 145 | } 146 | 147 | // GetPools returns pools. 148 | func (s *Store) GetPools(ctx context.Context) ([]domain.Pool, error) { 149 | rows, err := s.db.QueryContext(ctx, "SHOW POOLS") 150 | if err != nil { 151 | return nil, err 152 | } 153 | defer rows.Close() 154 | 155 | columns, err := rows.Columns() 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | var row pool 161 | var pools []pool 162 | 163 | for rows.Next() { 164 | dest := make([]any, 0, len(columns)) 165 | 166 | for _, column := range columns { 167 | switch column { 168 | case "database": 169 | dest = append(dest, &row.Database) 170 | case "user": 171 | dest = append(dest, &row.User) 172 | case "cl_active": 173 | dest = append(dest, &row.Active) 174 | case "cl_waiting": 175 | dest = append(dest, &row.Waiting) 176 | case "cl_cancel_req": 177 | dest = append(dest, &row.CancelReq) 178 | case "cl_active_cancel_req": 179 | dest = append(dest, &row.ActiveCancelReq) 180 | case "cl_waiting_cancel_req": 181 | dest = append(dest, &row.WaitingCancelReq) 182 | case "sv_active": 183 | dest = append(dest, &row.ServerActive) 184 | case "sv_active_cancel": 185 | dest = append(dest, &row.ServerActiveCancel) 186 | case "sv_being_canceled": 187 | dest = append(dest, &row.ServerBeingCanceled) 188 | case "sv_idle": 189 | dest = append(dest, &row.ServerIdle) 190 | case "sv_used": 191 | dest = append(dest, &row.ServerUsed) 192 | case "sv_tested": 193 | dest = append(dest, &row.ServerTested) 194 | case "sv_login": 195 | dest = append(dest, &row.ServerLogin) 196 | case "maxwait": 197 | dest = append(dest, &row.MaxWait) 198 | case "maxwait_us": 199 | dest = append(dest, &row.MaxWaitUs) 200 | case "pool_mode": 201 | dest = append(dest, &row.PoolMode) 202 | case "load_balance_hosts": 203 | dest = append(dest, &row.LoadBalanceHosts) 204 | default: 205 | return nil, fmt.Errorf("unexpected column: %v", column) 206 | } 207 | } 208 | 209 | if err := rows.Scan(dest...); err != nil { 210 | return nil, err 211 | } 212 | pools = append(pools, row) 213 | } 214 | 215 | if err := rows.Err(); err != nil { 216 | return nil, err 217 | } 218 | 219 | var result []domain.Pool 220 | 221 | for _, row := range pools { 222 | result = append(result, domain.Pool{ 223 | Database: row.Database, 224 | User: row.User, 225 | Active: row.Active, 226 | Waiting: row.Waiting, 227 | ServerActive: row.ServerActive, 228 | ServerIdle: row.ServerIdle, 229 | ServerUsed: row.ServerUsed, 230 | ServerTested: row.ServerTested, 231 | ServerLogin: row.ServerLogin, 232 | MaxWait: row.MaxWait, 233 | MaxWaitUs: row.MaxWaitUs, 234 | PoolMode: row.PoolMode.String, 235 | }) 236 | } 237 | 238 | return result, nil 239 | } 240 | 241 | // GetDatabases returns databases. 242 | func (s *Store) GetDatabases(ctx context.Context) ([]domain.Database, error) { 243 | rows, err := s.db.QueryContext(ctx, "SHOW DATABASES") 244 | if err != nil { 245 | return nil, err 246 | } 247 | defer rows.Close() 248 | 249 | columns, err := rows.Columns() 250 | if err != nil { 251 | return nil, err 252 | } 253 | 254 | var row database 255 | var databases []database 256 | 257 | for rows.Next() { 258 | dest := make([]any, 0, len(columns)) 259 | 260 | for _, column := range columns { 261 | switch column { 262 | case "database": 263 | dest = append(dest, &row.Database) 264 | case "name": 265 | dest = append(dest, &row.Name) 266 | case "host": 267 | dest = append(dest, &row.Host) 268 | case "port": 269 | dest = append(dest, &row.Port) 270 | case "force_user": 271 | dest = append(dest, &row.ForceUser) 272 | case "pool_size": 273 | dest = append(dest, &row.PoolSize) 274 | case "min_pool_size": 275 | dest = append(dest, &row.MinPoolSize) 276 | case "reserve_pool_size": // renamed in PgBouncer 1.24 https://github.com/pgbouncer/pgbouncer/pull/1232 277 | dest = append(dest, &row.ReservePoolSize) 278 | case "reserve_pool": 279 | dest = append(dest, &row.ReservePoolSize) 280 | case "server_lifetime": 281 | dest = append(dest, &row.ServerLifetime) 282 | case "pool_mode": 283 | dest = append(dest, &row.PoolMode) 284 | case "max_connections": 285 | dest = append(dest, &row.MaxConnections) 286 | case "current_connections": 287 | dest = append(dest, &row.CurrentConnections) 288 | case "paused": 289 | dest = append(dest, &row.Paused) 290 | case "disabled": 291 | dest = append(dest, &row.Disabled) 292 | case "load_balance_hosts": 293 | dest = append(dest, &row.LoadBalanceHosts) 294 | case "max_client_connections": 295 | dest = append(dest, &row.MaxClientConnections) 296 | case "current_client_connections": 297 | dest = append(dest, &row.CurrentClientConnections) 298 | default: 299 | return nil, fmt.Errorf("unexpected column: %v", column) 300 | } 301 | } 302 | 303 | if err := rows.Scan(dest...); err != nil { 304 | return nil, err 305 | } 306 | databases = append(databases, row) 307 | } 308 | 309 | if err := rows.Err(); err != nil { 310 | return nil, err 311 | } 312 | 313 | var result []domain.Database 314 | 315 | for _, row := range databases { 316 | result = append(result, domain.Database{ 317 | Name: row.Name, 318 | Host: row.Host.String, 319 | Port: row.Port, 320 | Database: row.Database, 321 | ForceUser: row.ForceUser.String, 322 | PoolSize: row.PoolSize, 323 | ReservePoolSize: row.ReservePoolSize, 324 | PoolMode: row.PoolMode.String, 325 | MaxConnections: row.MaxConnections, 326 | CurrentConnections: row.CurrentConnections, 327 | Paused: row.Paused, 328 | Disabled: row.Disabled, 329 | ServerLifetime: row.ServerLifetime, 330 | }) 331 | } 332 | 333 | return result, nil 334 | } 335 | 336 | // GetLists returns lists. 337 | func (s *Store) GetLists(ctx context.Context) ([]domain.List, error) { 338 | rows, err := s.db.QueryContext(ctx, "SHOW LISTS") 339 | if err != nil { 340 | return nil, err 341 | } 342 | defer rows.Close() 343 | 344 | columns, err := rows.Columns() 345 | if err != nil { 346 | return nil, err 347 | } 348 | 349 | var row domain.List 350 | var lists []domain.List 351 | 352 | for rows.Next() { 353 | dest := make([]any, 0, len(columns)) 354 | 355 | for _, column := range columns { 356 | switch column { 357 | case "list": 358 | dest = append(dest, &row.List) 359 | case "items": 360 | dest = append(dest, &row.Items) 361 | default: 362 | return nil, fmt.Errorf("unexpected column: %v", column) 363 | } 364 | } 365 | 366 | if err := rows.Scan(dest...); err != nil { 367 | return nil, err 368 | } 369 | lists = append(lists, row) 370 | } 371 | 372 | if err := rows.Err(); err != nil { 373 | return nil, err 374 | } 375 | 376 | return lists, nil 377 | } 378 | 379 | // Check checks the health of the store. 380 | func (s *Store) Check(ctx context.Context) error { 381 | // we cant use db.Ping because it is making a ";" sql query which pgbouncer does not support 382 | rows, err := s.db.QueryContext(ctx, "SHOW VERSION") 383 | if err != nil { 384 | return err 385 | } 386 | return rows.Close() 387 | } 388 | -------------------------------------------------------------------------------- /internal/sqlstore/sql_test.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "testing" 7 | 8 | "github.com/DATA-DOG/go-sqlmock" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGetStats(t *testing.T) { 13 | db, mock, err := sqlmock.New() 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | defer db.Close() 18 | 19 | st := New(db) 20 | 21 | data := map[string]any{ 22 | "database": "pgbouncer", 23 | "total_xact_count": 1, 24 | "total_query_count": 2, 25 | "total_received": 3, 26 | "total_sent": 4, 27 | "total_xact_time": 5, 28 | "total_query_time": 6, 29 | "total_wait_time": 7, 30 | "total_server_assignment_count": 8, 31 | "avg_xact_count": 9, 32 | "avg_query_count": 10, 33 | "avg_recv": 11, 34 | "avg_sent": 12, 35 | "avg_xact_time": 13, 36 | "avg_query_time": 14, 37 | "avg_wait_time": 15, 38 | "avg_server_assignment_count": 16, 39 | } 40 | 41 | mock.ExpectQuery("SHOW STATS").WillReturnRows(mapToRows(data)) 42 | 43 | stats, err := st.GetStats(context.Background()) 44 | require.NoError(t, err) 45 | require.NoError(t, mock.ExpectationsWereMet()) 46 | 47 | stat := stats[0] 48 | require.Equal(t, data["database"].(string), stat.Database) 49 | require.Equal(t, int64(data["total_xact_count"].(int)), stat.TotalXactCount) 50 | require.Equal(t, int64(data["total_query_count"].(int)), stat.TotalQueryCount) 51 | require.Equal(t, int64(data["total_received"].(int)), stat.TotalReceived) 52 | require.Equal(t, int64(data["total_sent"].(int)), stat.TotalSent) 53 | require.Equal(t, int64(data["total_xact_time"].(int)), stat.TotalXactTime) 54 | require.Equal(t, int64(data["total_query_time"].(int)), stat.TotalQueryTime) 55 | require.Equal(t, int64(data["total_wait_time"].(int)), stat.TotalWaitTime) 56 | require.Equal(t, int64(data["total_server_assignment_count"].(int)), stat.TotalServerAssignmentCount) 57 | require.Equal(t, int64(data["avg_xact_count"].(int)), stat.AverageXactCount) 58 | require.Equal(t, int64(data["avg_query_count"].(int)), stat.AverageQueryCount) 59 | require.Equal(t, int64(data["avg_recv"].(int)), stat.AverageReceived) 60 | require.Equal(t, int64(data["avg_sent"].(int)), stat.AverageSent) 61 | require.Equal(t, int64(data["avg_xact_time"].(int)), stat.AverageXactTime) 62 | require.Equal(t, int64(data["avg_query_time"].(int)), stat.AverageQueryTime) 63 | require.Equal(t, int64(data["avg_wait_time"].(int)), stat.AverageWaitTime) 64 | require.Equal(t, int64(data["avg_server_assignment_count"].(int)), stat.AverageServerAssignmentCount) 65 | } 66 | 67 | func TestGetPools(t *testing.T) { 68 | db, mock, err := sqlmock.New() 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | defer db.Close() 73 | 74 | st := New(db) 75 | 76 | data := map[string]any{ 77 | "database": "pgbouncer", 78 | "user": "myuser", 79 | "cl_active": 1, 80 | "cl_waiting": 2, 81 | "sv_active": 3, 82 | "sv_idle": 4, 83 | "sv_used": 5, 84 | "sv_tested": 6, 85 | "sv_login": 7, 86 | "maxwait": 8, 87 | "maxwait_us": 9, 88 | "pool_mode": "transaction", 89 | } 90 | 91 | mock.ExpectQuery("SHOW POOLS").WillReturnRows(mapToRows(data)) 92 | 93 | pools, err := st.GetPools(context.Background()) 94 | require.NoError(t, err) 95 | require.NoError(t, mock.ExpectationsWereMet()) 96 | 97 | pool := pools[0] 98 | require.Equal(t, data["database"].(string), pool.Database) 99 | require.Equal(t, data["user"].(string), pool.User) 100 | require.Equal(t, int64(data["cl_active"].(int)), pool.Active) 101 | require.Equal(t, int64(data["cl_waiting"].(int)), pool.Waiting) 102 | require.Equal(t, int64(data["sv_active"].(int)), pool.ServerActive) 103 | require.Equal(t, int64(data["sv_idle"].(int)), pool.ServerIdle) 104 | require.Equal(t, int64(data["sv_used"].(int)), pool.ServerUsed) 105 | require.Equal(t, int64(data["sv_tested"].(int)), pool.ServerTested) 106 | require.Equal(t, int64(data["sv_login"].(int)), pool.ServerLogin) 107 | require.Equal(t, int64(data["maxwait"].(int)), pool.MaxWait) 108 | require.Equal(t, int64(data["maxwait_us"].(int)), pool.MaxWaitUs) 109 | require.Equal(t, data["pool_mode"].(string), pool.PoolMode) 110 | } 111 | 112 | func TestGetDatabases(t *testing.T) { 113 | db, mock, err := sqlmock.New() 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | defer db.Close() 118 | 119 | st := New(db) 120 | 121 | data := map[string]any{ 122 | "database": "pgbouncer", 123 | "name": "myname", 124 | "host": "localhost", 125 | "port": 23, 126 | "force_user": "myuser", 127 | "pool_size": 4, 128 | "reserve_pool_size": 5, 129 | "reserve_pool": 5, 130 | "pool_mode": "transaction", 131 | "max_connections": 7, 132 | "current_connections": 8, 133 | "paused": 9, 134 | "disabled": 10, 135 | "server_lifetime": 11, 136 | } 137 | 138 | mock.ExpectQuery("SHOW DATABASES").WillReturnRows(mapToRows(data)) 139 | 140 | databases, err := st.GetDatabases(context.Background()) 141 | require.NoError(t, err) 142 | require.NoError(t, mock.ExpectationsWereMet()) 143 | 144 | database := databases[0] 145 | require.Equal(t, data["database"].(string), database.Database) 146 | require.Equal(t, data["name"].(string), database.Name) 147 | require.Equal(t, data["host"].(string), database.Host) 148 | require.Equal(t, int64(data["port"].(int)), database.Port) 149 | require.Equal(t, data["force_user"].(string), database.ForceUser) 150 | require.Equal(t, int64(data["pool_size"].(int)), database.PoolSize) 151 | require.Equal(t, int64(data["reserve_pool_size"].(int)), database.ReservePoolSize) 152 | require.Equal(t, int64(data["reserve_pool"].(int)), database.ReservePoolSize) 153 | require.Equal(t, data["pool_mode"].(string), database.PoolMode) 154 | require.Equal(t, int64(data["max_connections"].(int)), database.MaxConnections) 155 | require.Equal(t, int64(data["current_connections"].(int)), database.CurrentConnections) 156 | require.Equal(t, int64(data["paused"].(int)), database.Paused) 157 | require.Equal(t, int64(data["disabled"].(int)), database.Disabled) 158 | require.Equal(t, int64(data["server_lifetime"].(int)), database.ServerLifetime) 159 | } 160 | 161 | func TestGetLists(t *testing.T) { 162 | db, mock, err := sqlmock.New() 163 | if err != nil { 164 | t.Fatal(err) 165 | } 166 | defer db.Close() 167 | 168 | st := New(db) 169 | 170 | data := map[string]any{ 171 | "list": "mylist", 172 | "items": 6, 173 | } 174 | 175 | mock.ExpectQuery("SHOW LISTS").WillReturnRows(mapToRows(data)) 176 | 177 | lists, err := st.GetLists(context.Background()) 178 | require.NoError(t, err) 179 | require.NoError(t, mock.ExpectationsWereMet()) 180 | 181 | list := lists[0] 182 | require.Equal(t, data["list"].(string), list.List) 183 | require.Equal(t, int64(data["items"].(int)), list.Items) 184 | } 185 | 186 | func mapToRows(data map[string]any) *sqlmock.Rows { 187 | columns := make([]string, 0, len(data)) 188 | values := make([]driver.Value, 0, len(data)) 189 | for k, v := range data { 190 | columns = append(columns, k) 191 | values = append(values, v) 192 | } 193 | rows := sqlmock.NewRows(columns) 194 | rows.AddRow(values...) 195 | return rows 196 | } 197 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/jbub/pgbouncer_exporter/cmd" 9 | "github.com/jbub/pgbouncer_exporter/internal/collector" 10 | 11 | _ "github.com/lib/pq" 12 | "github.com/prometheus/common/version" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | func main() { 17 | app := &cli.App{ 18 | Name: collector.Name, 19 | Usage: collector.Name, 20 | Flags: []cli.Flag{ 21 | &cli.StringFlag{ 22 | Name: "web.listen-address", 23 | Usage: "Address on which to expose metrics and web interface.", 24 | EnvVars: []string{"WEB_LISTEN_ADDRESS"}, 25 | Value: ":9127", 26 | }, 27 | &cli.StringFlag{ 28 | Name: "web.telemetry-path", 29 | Usage: "Path under which to expose metrics.", 30 | EnvVars: []string{"WEB_TELEMETRY_PATH"}, 31 | Value: "/metrics", 32 | }, 33 | &cli.StringFlag{ 34 | Name: "database-url", 35 | Usage: "Database connection url.", 36 | EnvVars: []string{"DATABASE_URL"}, 37 | }, 38 | &cli.BoolFlag{ 39 | Name: "export-stats", 40 | Usage: "Export stats.", 41 | EnvVars: []string{"EXPORT_STATS"}, 42 | Value: true, 43 | }, 44 | &cli.BoolFlag{ 45 | Name: "export-pools", 46 | Usage: "Export pools.", 47 | EnvVars: []string{"EXPORT_POOLS"}, 48 | Value: true, 49 | }, 50 | &cli.BoolFlag{ 51 | Name: "export-databases", 52 | Usage: "Export databases.", 53 | EnvVars: []string{"EXPORT_DATABASES"}, 54 | Value: true, 55 | }, 56 | &cli.BoolFlag{ 57 | Name: "export-lists", 58 | Usage: "Export lists.", 59 | EnvVars: []string{"EXPORT_LISTS"}, 60 | Value: true, 61 | }, 62 | &cli.DurationFlag{ 63 | Name: "store-timeout", 64 | Usage: "Per method store timeout.", 65 | EnvVars: []string{"STORE_TIMEOUT"}, 66 | Value: time.Second * 2, 67 | }, 68 | &cli.StringFlag{ 69 | Name: "default-labels", 70 | Usage: "Default prometheus labels applied to all metrics. Format: label1=value1 label2=value2", 71 | EnvVars: []string{"DEFAULT_LABELS"}, 72 | }, 73 | }, 74 | Commands: []*cli.Command{ 75 | cmd.Server, 76 | cmd.Health, 77 | }, 78 | Version: version.Info(), 79 | } 80 | 81 | if err := app.Run(os.Args); err != nil { 82 | log.Fatal(err) 83 | } 84 | } 85 | --------------------------------------------------------------------------------