├── .circleci └── config.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── container_description.yml │ └── golangci-lint.yml ├── .gitignore ├── .golangci.yml ├── .promu.yml ├── .yamllint ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── Makefile.common ├── NOTICE ├── README-RDS.md ├── README.md ├── SECURITY.md ├── VERSION ├── cmd └── postgres_exporter │ ├── datasource.go │ ├── main.go │ ├── namespace.go │ ├── pg_setting.go │ ├── pg_setting_test.go │ ├── postgres_exporter.go │ ├── postgres_exporter_integration_test.go │ ├── postgres_exporter_test.go │ ├── probe.go │ ├── queries.go │ ├── server.go │ ├── tests │ ├── docker-postgres-replication │ │ ├── Dockerfile │ │ ├── Dockerfile.p2 │ │ ├── README.md │ │ ├── docker-compose.yml │ │ ├── docker-entrypoint.sh │ │ └── setup-replication.sh │ ├── test-smoke │ ├── user_queries_ok.yaml │ ├── user_queries_test.yaml │ ├── username_file │ └── userpass_file │ └── util.go ├── collector ├── collector.go ├── collector_test.go ├── instance.go ├── pg_database.go ├── pg_database_test.go ├── pg_database_wraparound.go ├── pg_database_wraparound_test.go ├── pg_locks.go ├── pg_locks_test.go ├── pg_long_running_transactions.go ├── pg_long_running_transactions_test.go ├── pg_postmaster.go ├── pg_postmaster_test.go ├── pg_process_idle.go ├── pg_replication.go ├── pg_replication_slot.go ├── pg_replication_slot_test.go ├── pg_replication_test.go ├── pg_roles.go ├── pg_roles_test.go ├── pg_stat_activity_autovacuum.go ├── pg_stat_activity_autovacuum_test.go ├── pg_stat_bgwriter.go ├── pg_stat_bgwriter_test.go ├── pg_stat_checkpointer.go ├── pg_stat_checkpointer_test.go ├── pg_stat_database.go ├── pg_stat_database_test.go ├── pg_stat_progress_vacuum.go ├── pg_stat_progress_vacuum_test.go ├── pg_stat_statements.go ├── pg_stat_statements_test.go ├── pg_stat_user_tables.go ├── pg_stat_user_tables_test.go ├── pg_stat_walreceiver.go ├── pg_stat_walreceiver_test.go ├── pg_statio_user_indexes.go ├── pg_statio_user_indexes_test.go ├── pg_statio_user_tables.go ├── pg_statio_user_tables_test.go ├── pg_wal.go ├── pg_wal_test.go ├── pg_xlog_location.go ├── pg_xlog_location_test.go └── probe.go ├── config ├── config.go ├── config_test.go ├── dsn.go ├── dsn_test.go └── testdata │ ├── config-bad-auth-module.yaml │ ├── config-bad-extra-field.yaml │ └── config-good.yaml ├── gh-assets-clone.sh ├── gh-metrics-push.sh ├── go.mod ├── go.sum ├── postgres-metrics-get-changes.sh ├── postgres_exporter.rc ├── postgres_exporter_integration_test_script ├── postgres_mixin ├── .gitignore ├── Makefile ├── README.md ├── alerts │ ├── alerts.libsonnet │ └── postgres.libsonnet ├── config.libsonnet ├── dashboards │ ├── dashboards.libsonnet │ └── postgres-overview.json ├── go.mod └── mixin.libsonnet └── queries.yaml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2.1 3 | 4 | orbs: 5 | prometheus: prometheus/prometheus@0.17.1 6 | 7 | executors: 8 | # This must match .promu.yml. 9 | golang: 10 | docker: 11 | - image: cimg/go:1.24 12 | 13 | jobs: 14 | test: 15 | executor: golang 16 | 17 | steps: 18 | - prometheus/setup_environment 19 | - run: GOHOSTARCH=386 GOARCH=386 make test 20 | - run: make 21 | - prometheus/store_artifact: 22 | file: postgres_exporter 23 | 24 | integration: 25 | docker: 26 | - image: cimg/go:1.24 27 | - image: << parameters.postgres_image >> 28 | environment: 29 | POSTGRES_DB: circle_test 30 | POSTGRES_USER: postgres 31 | POSTGRES_PASSWORD: test 32 | 33 | parameters: 34 | postgres_image: 35 | type: string 36 | 37 | environment: 38 | DATA_SOURCE_NAME: 'postgresql://postgres:test@localhost:5432/circle_test?sslmode=disable' 39 | GOOPTS: '-v -tags integration' 40 | 41 | steps: 42 | - checkout 43 | - setup_remote_docker 44 | - run: docker version 45 | - run: make build 46 | - run: make test 47 | 48 | workflows: 49 | version: 2 50 | postgres_exporter: 51 | jobs: 52 | - test: 53 | filters: 54 | tags: 55 | only: /.*/ 56 | - integration: 57 | matrix: 58 | parameters: 59 | postgres_image: 60 | - circleci/postgres:11 61 | - circleci/postgres:12 62 | - circleci/postgres:13 63 | - cimg/postgres:14.9 64 | - cimg/postgres:15.4 65 | - cimg/postgres:16.0 66 | - cimg/postgres:17.0 67 | - prometheus/build: 68 | name: build 69 | parallelism: 3 70 | promu_opts: "-p linux/amd64 -p windows/amd64 -p linux/arm64 -p darwin/amd64 -p darwin/arm64 -p linux/386" 71 | filters: 72 | tags: 73 | ignore: /^v.*/ 74 | branches: 75 | ignore: /^(main|master|release-.*|.*build-all.*)$/ 76 | - prometheus/build: 77 | name: build_all 78 | parallelism: 12 79 | filters: 80 | branches: 81 | only: /^(main|master|release-.*|.*build-all.*)$/ 82 | tags: 83 | only: /^v.*/ 84 | - prometheus/publish_master: 85 | context: org-context 86 | docker_hub_organization: prometheuscommunity 87 | quay_io_organization: prometheuscommunity 88 | requires: 89 | - test 90 | - build_all 91 | filters: 92 | branches: 93 | only: master 94 | - prometheus/publish_release: 95 | context: org-context 96 | docker_hub_organization: prometheuscommunity 97 | quay_io_organization: prometheuscommunity 98 | requires: 99 | - test 100 | - build_all 101 | filters: 102 | tags: 103 | only: /^v.*/ 104 | branches: 105 | ignore: /.*/ 106 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve. 4 | title: '' 5 | assignees: '' 6 | --- 7 | 8 | 17 | 18 | **What did you do?** 19 | 20 | **What did you expect to see?** 21 | 22 | **What did you see instead? Under which circumstances?** 23 | 24 | **Environment** 25 | 26 | * System information: 27 | 28 | insert output of `uname -srm` here 29 | 30 | * postgres_exporter version: 31 | 32 | insert output of `postgres_exporter --version` here 33 | 34 | * postgres_exporter flags: 35 | 36 | ``` 37 | insert list of flags used here 38 | ``` 39 | 40 | * PostgreSQL version: 41 | 42 | insert PostgreSQL version here 43 | 44 | * Logs: 45 | ``` 46 | insert logs relevant to the issue here 47 | ``` 48 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Prometheus community support 4 | url: https://prometheus.io/community/ 5 | about: List of communication channels for the Prometheus community. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 18 | ## Proposal 19 | **Use case. Why is this important?** 20 | 21 | *“Nice to have” is not a good use case. :)* 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/workflows/container_description.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Push README to Docker Hub 3 | on: 4 | push: 5 | paths: 6 | - "README.md" 7 | - "README-containers.md" 8 | - ".github/workflows/container_description.yml" 9 | branches: [ main, master ] 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | PushDockerHubReadme: 16 | runs-on: ubuntu-latest 17 | name: Push README to Docker Hub 18 | if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. 19 | steps: 20 | - name: git checkout 21 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | - name: Set docker hub repo name 23 | run: echo "DOCKER_REPO_NAME=$(make docker-repo-name)" >> $GITHUB_ENV 24 | - name: Push README to Dockerhub 25 | uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 26 | env: 27 | DOCKER_USER: ${{ secrets.DOCKER_HUB_LOGIN }} 28 | DOCKER_PASS: ${{ secrets.DOCKER_HUB_PASSWORD }} 29 | with: 30 | destination_container_repo: ${{ env.DOCKER_REPO_NAME }} 31 | provider: dockerhub 32 | short_description: ${{ env.DOCKER_REPO_NAME }} 33 | # Empty string results in README-containers.md being pushed if it 34 | # exists. Otherwise, README.md is pushed. 35 | readme_file: '' 36 | 37 | PushQuayIoReadme: 38 | runs-on: ubuntu-latest 39 | name: Push README to quay.io 40 | if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. 41 | steps: 42 | - name: git checkout 43 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 44 | - name: Set quay.io org name 45 | run: echo "DOCKER_REPO=$(echo quay.io/${GITHUB_REPOSITORY_OWNER} | tr -d '-')" >> $GITHUB_ENV 46 | - name: Set quay.io repo name 47 | run: echo "DOCKER_REPO_NAME=$(make docker-repo-name)" >> $GITHUB_ENV 48 | - name: Push README to quay.io 49 | uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 50 | env: 51 | DOCKER_APIKEY: ${{ secrets.QUAY_IO_API_TOKEN }} 52 | with: 53 | destination_container_repo: ${{ env.DOCKER_REPO_NAME }} 54 | provider: quay 55 | # Empty string results in README-containers.md being pushed if it 56 | # exists. Otherwise, README.md is pushed. 57 | readme_file: '' 58 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # This action is synced from https://github.com/prometheus/prometheus 3 | name: golangci-lint 4 | on: 5 | push: 6 | paths: 7 | - "go.sum" 8 | - "go.mod" 9 | - "**.go" 10 | - "scripts/errcheck_excludes.txt" 11 | - ".github/workflows/golangci-lint.yml" 12 | - ".golangci.yml" 13 | pull_request: 14 | 15 | permissions: # added using https://github.com/step-security/secure-repo 16 | contents: read 17 | 18 | jobs: 19 | golangci: 20 | permissions: 21 | contents: read # for actions/checkout to fetch code 22 | pull-requests: read # for golangci/golangci-lint-action to fetch pull requests 23 | name: lint 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | - name: Install Go 29 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0 30 | with: 31 | go-version: 1.24.x 32 | - name: Install snmp_exporter/generator dependencies 33 | run: sudo apt-get update && sudo apt-get -y install libsnmp-dev 34 | if: github.repository == 'prometheus/snmp_exporter' 35 | - name: Lint 36 | uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0 37 | with: 38 | args: --verbose 39 | version: v2.1.5 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build 2 | /postgres_exporter 3 | /postgres_exporter_integration_test 4 | *.tar.gz 5 | *.test 6 | *-stamp 7 | /.idea 8 | /.vscode 9 | *.iml 10 | /cover.out 11 | /cover.*.out 12 | /.coverage 13 | /bin 14 | /release 15 | /*.prom 16 | /.metrics.*.*.prom 17 | /.metrics.*.*.prom.unique 18 | /.assets-branch 19 | /.metrics.*.added 20 | /.metrics.*.removed 21 | /tools/src 22 | /vendor 23 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - misspell 5 | - revive 6 | settings: 7 | errcheck: 8 | exclude-functions: 9 | - (github.com/go-kit/log.Logger).Log 10 | revive: 11 | rules: 12 | - name: unused-parameter 13 | severity: warning 14 | disabled: true 15 | exclusions: 16 | generated: lax 17 | presets: 18 | - comments 19 | - common-false-positives 20 | - legacy 21 | - std-error-handling 22 | rules: 23 | - linters: 24 | - errcheck 25 | path: _test.go 26 | paths: 27 | - third_party$ 28 | - builtin$ 29 | - examples$ 30 | formatters: 31 | exclusions: 32 | generated: lax 33 | paths: 34 | - third_party$ 35 | - builtin$ 36 | - examples$ 37 | -------------------------------------------------------------------------------- /.promu.yml: -------------------------------------------------------------------------------- 1 | go: 2 | # This must match .circle/config.yml. 3 | version: 1.24 4 | repository: 5 | path: github.com/prometheus-community/postgres_exporter 6 | build: 7 | binaries: 8 | - name: postgres_exporter 9 | path: ./cmd/postgres_exporter 10 | ldflags: | 11 | -X github.com/prometheus/common/version.Version={{.Version}} 12 | -X github.com/prometheus/common/version.Revision={{.Revision}} 13 | -X github.com/prometheus/common/version.Branch={{.Branch}} 14 | -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} 15 | -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} 16 | tarball: 17 | files: 18 | - LICENSE 19 | - NOTICE 20 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | ignore: | 4 | **/node_modules 5 | 6 | rules: 7 | braces: 8 | max-spaces-inside: 1 9 | level: error 10 | brackets: 11 | max-spaces-inside: 1 12 | level: error 13 | commas: disable 14 | comments: disable 15 | comments-indentation: disable 16 | document-start: disable 17 | indentation: 18 | spaces: consistent 19 | indent-sequences: consistent 20 | key-duplicates: 21 | ignore: | 22 | config/testdata/section_key_dup.bad.yml 23 | line-length: disable 24 | truthy: 25 | check-keys: false 26 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Prometheus Community Code of Conduct 2 | 3 | Prometheus follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH="amd64" 2 | ARG OS="linux" 3 | FROM quay.io/prometheus/busybox-${OS}-${ARCH}:latest 4 | LABEL maintainer="The Prometheus Authors " 5 | 6 | ARG ARCH="amd64" 7 | ARG OS="linux" 8 | COPY .build/${OS}-${ARCH}/postgres_exporter /bin/postgres_exporter 9 | 10 | EXPOSE 9187 11 | USER nobody 12 | ENTRYPOINT [ "/bin/postgres_exporter" ] 13 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | * Ben Kochie @SuperQ 2 | * William Rouesnel @wrouesnel 3 | * Joe Adams @sysadmind 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Ensure that 'all' is the default target otherwise it will be the first target from Makefile.common. 2 | all:: 3 | 4 | # Needs to be defined before including Makefile.common to auto-generate targets 5 | DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le 6 | DOCKER_REPO ?= prometheuscommunity 7 | 8 | include Makefile.common 9 | 10 | DOCKER_IMAGE_NAME ?= postgres-exporter 11 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2018 William Rouesnel 2 | Copyright 2021 The Prometheus Authors 3 | -------------------------------------------------------------------------------- /README-RDS.md: -------------------------------------------------------------------------------- 1 | # Using Postgres-Exporter with AWS:RDS 2 | 3 | ### When using postgres-exporter with Amazon Web Services' RDS, the 4 | rolname "rdsadmin" and datname "rdsadmin" must be excluded. 5 | 6 | I had success running docker container 'quay.io/prometheuscommunity/postgres-exporter:latest' 7 | with queries.yaml as the PG_EXPORTER_EXTEND_QUERY_PATH. errors 8 | mentioned in issue#335 appeared and I had to modify the 9 | 'pg_stat_statements' query with the following: 10 | `WHERE t2.rolname != 'rdsadmin'` 11 | 12 | Running postgres-exporter in a container like so: 13 | ``` 14 | DBNAME='postgres' 15 | PGUSER='postgres' 16 | PGPASS='psqlpasswd123' 17 | PGHOST='name.blahblah.us-east-1.rds.amazonaws.com' 18 | docker run --rm --detach \ 19 | --name "postgresql_exporter_rds" \ 20 | --publish 9187:9187 \ 21 | --volume=/etc/prometheus/postgresql-exporter/queries.yaml:/var/lib/postgresql/queries.yaml \ 22 | -e DATA_SOURCE_NAME="postgresql://${PGUSER}:${PGPASS}@${PGHOST}:5432/${DBNAME}?sslmode=disable" \ 23 | -e PG_EXPORTER_EXCLUDE_DATABASES=rdsadmin \ 24 | -e PG_EXPORTER_DISABLE_DEFAULT_METRICS=true \ 25 | -e PG_EXPORTER_DISABLE_SETTINGS_METRICS=true \ 26 | -e PG_EXPORTER_EXTEND_QUERY_PATH='/var/lib/postgresql/queries.yaml' \ 27 | quay.io/prometheuscommunity/postgres-exporter 28 | ``` 29 | 30 | ### Expected changes to RDS: 31 | + see stackoverflow notes 32 | (https://stackoverflow.com/questions/43926499/amazon-postgres-rds-pg-stat-statements-not-loaded#43931885) 33 | + you must also use a specific RDS parameter_group that includes the following: 34 | ``` 35 | shared_preload_libraries = "pg_stat_statements,pg_hint_plan" 36 | ``` 37 | + lastly, you must reboot the RDS instance. 38 | 39 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting a security issue 2 | 3 | The Prometheus security policy, including how to report vulnerabilities, can be 4 | found here: 5 | 6 | 7 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.17.1 2 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/datasource.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "net/url" 19 | "os" 20 | "regexp" 21 | "strings" 22 | 23 | "github.com/prometheus/client_golang/prometheus" 24 | ) 25 | 26 | func (e *Exporter) discoverDatabaseDSNs() []string { 27 | // connstring syntax is complex (and not sure if even regular). 28 | // we don't need to parse it, so just superficially validate that it starts 29 | // with a valid-ish keyword pair 30 | connstringRe := regexp.MustCompile(`^ *[a-zA-Z0-9]+ *= *[^= ]+`) 31 | 32 | dsns := make(map[string]struct{}) 33 | for _, dsn := range e.dsn { 34 | var dsnURI *url.URL 35 | var dsnConnstring string 36 | 37 | if strings.HasPrefix(dsn, "postgresql://") || strings.HasPrefix(dsn, "postgres://") { 38 | var err error 39 | dsnURI, err = url.Parse(dsn) 40 | if err != nil { 41 | logger.Error("Unable to parse DSN as URI", "dsn", loggableDSN(dsn), "err", err) 42 | continue 43 | } 44 | } else if connstringRe.MatchString(dsn) { 45 | dsnConnstring = dsn 46 | } else { 47 | logger.Error("Unable to parse DSN as either URI or connstring", "dsn", loggableDSN(dsn)) 48 | continue 49 | } 50 | 51 | server, err := e.servers.GetServer(dsn) 52 | if err != nil { 53 | logger.Error("Error opening connection to database", "dsn", loggableDSN(dsn), "err", err) 54 | continue 55 | } 56 | dsns[dsn] = struct{}{} 57 | 58 | // If autoDiscoverDatabases is true, set first dsn as master database (Default: false) 59 | server.master = true 60 | 61 | databaseNames, err := queryDatabases(server) 62 | if err != nil { 63 | logger.Error("Error querying databases", "dsn", loggableDSN(dsn), "err", err) 64 | continue 65 | } 66 | for _, databaseName := range databaseNames { 67 | if contains(e.excludeDatabases, databaseName) { 68 | continue 69 | } 70 | 71 | if len(e.includeDatabases) != 0 && !contains(e.includeDatabases, databaseName) { 72 | continue 73 | } 74 | 75 | if dsnURI != nil { 76 | dsnURI.Path = databaseName 77 | dsn = dsnURI.String() 78 | } else { 79 | // replacing one dbname with another is complicated. 80 | // just append new dbname to override. 81 | dsn = fmt.Sprintf("%s dbname=%s", dsnConnstring, databaseName) 82 | } 83 | dsns[dsn] = struct{}{} 84 | } 85 | } 86 | 87 | result := make([]string, len(dsns)) 88 | index := 0 89 | for dsn := range dsns { 90 | result[index] = dsn 91 | index++ 92 | } 93 | 94 | return result 95 | } 96 | 97 | func (e *Exporter) scrapeDSN(ch chan<- prometheus.Metric, dsn string) error { 98 | server, err := e.servers.GetServer(dsn) 99 | 100 | if err != nil { 101 | return &ErrorConnectToServer{fmt.Sprintf("Error opening connection to database (%s): %s", loggableDSN(dsn), err.Error())} 102 | } 103 | 104 | // Check if autoDiscoverDatabases is false, set dsn as master database (Default: false) 105 | if !e.autoDiscoverDatabases { 106 | server.master = true 107 | } 108 | 109 | // Check if map versions need to be updated 110 | if err := e.checkMapVersions(ch, server); err != nil { 111 | logger.Warn("Proceeding with outdated query maps, as the Postgres version could not be determined", "err", err) 112 | } 113 | 114 | return server.Scrape(ch, e.disableSettingsMetrics) 115 | } 116 | 117 | // try to get the DataSource 118 | // DATA_SOURCE_NAME always wins so we do not break older versions 119 | // reading secrets from files wins over secrets in environment variables 120 | // DATA_SOURCE_NAME > DATA_SOURCE_{USER|PASS}_FILE > DATA_SOURCE_{USER|PASS} 121 | func getDataSources() ([]string, error) { 122 | var dsn = os.Getenv("DATA_SOURCE_NAME") 123 | if len(dsn) != 0 { 124 | return strings.Split(dsn, ","), nil 125 | } 126 | 127 | var user, pass, uri string 128 | 129 | dataSourceUserFile := os.Getenv("DATA_SOURCE_USER_FILE") 130 | if len(dataSourceUserFile) != 0 { 131 | fileContents, err := os.ReadFile(dataSourceUserFile) 132 | if err != nil { 133 | return nil, fmt.Errorf("failed loading data source user file %s: %s", dataSourceUserFile, err.Error()) 134 | } 135 | user = strings.TrimSpace(string(fileContents)) 136 | } else { 137 | user = os.Getenv("DATA_SOURCE_USER") 138 | } 139 | 140 | dataSourcePassFile := os.Getenv("DATA_SOURCE_PASS_FILE") 141 | if len(dataSourcePassFile) != 0 { 142 | fileContents, err := os.ReadFile(dataSourcePassFile) 143 | if err != nil { 144 | return nil, fmt.Errorf("failed loading data source pass file %s: %s", dataSourcePassFile, err.Error()) 145 | } 146 | pass = strings.TrimSpace(string(fileContents)) 147 | } else { 148 | pass = os.Getenv("DATA_SOURCE_PASS") 149 | } 150 | 151 | ui := url.UserPassword(user, pass).String() 152 | dataSrouceURIFile := os.Getenv("DATA_SOURCE_URI_FILE") 153 | if len(dataSrouceURIFile) != 0 { 154 | fileContents, err := os.ReadFile(dataSrouceURIFile) 155 | if err != nil { 156 | return nil, fmt.Errorf("failed loading data source URI file %s: %s", dataSrouceURIFile, err.Error()) 157 | } 158 | uri = strings.TrimSpace(string(fileContents)) 159 | } else { 160 | uri = os.Getenv("DATA_SOURCE_URI") 161 | } 162 | 163 | // No datasources found. This allows us to support the multi-target pattern 164 | // without an explicit datasource. 165 | if uri == "" { 166 | return []string{}, nil 167 | } 168 | 169 | dsn = "postgresql://" + ui + "@" + uri 170 | 171 | return []string{dsn}, nil 172 | } 173 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "net/http" 19 | "os" 20 | "strings" 21 | 22 | "github.com/alecthomas/kingpin/v2" 23 | "github.com/prometheus-community/postgres_exporter/collector" 24 | "github.com/prometheus-community/postgres_exporter/config" 25 | "github.com/prometheus/client_golang/prometheus" 26 | versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" 27 | "github.com/prometheus/client_golang/prometheus/promhttp" 28 | "github.com/prometheus/common/promslog" 29 | "github.com/prometheus/common/promslog/flag" 30 | "github.com/prometheus/common/version" 31 | "github.com/prometheus/exporter-toolkit/web" 32 | "github.com/prometheus/exporter-toolkit/web/kingpinflag" 33 | ) 34 | 35 | var ( 36 | c = config.Handler{ 37 | Config: &config.Config{}, 38 | } 39 | 40 | configFile = kingpin.Flag("config.file", "Postgres exporter configuration file.").Default("postgres_exporter.yml").String() 41 | webConfig = kingpinflag.AddFlags(kingpin.CommandLine, ":9187") 42 | metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").Envar("PG_EXPORTER_WEB_TELEMETRY_PATH").String() 43 | disableDefaultMetrics = kingpin.Flag("disable-default-metrics", "Do not include default metrics.").Default("false").Envar("PG_EXPORTER_DISABLE_DEFAULT_METRICS").Bool() 44 | disableSettingsMetrics = kingpin.Flag("disable-settings-metrics", "Do not include pg_settings metrics.").Default("false").Envar("PG_EXPORTER_DISABLE_SETTINGS_METRICS").Bool() 45 | autoDiscoverDatabases = kingpin.Flag("auto-discover-databases", "Whether to discover the databases on a server dynamically. (DEPRECATED)").Default("false").Envar("PG_EXPORTER_AUTO_DISCOVER_DATABASES").Bool() 46 | queriesPath = kingpin.Flag("extend.query-path", "Path to custom queries to run. (DEPRECATED)").Default("").Envar("PG_EXPORTER_EXTEND_QUERY_PATH").String() 47 | onlyDumpMaps = kingpin.Flag("dumpmaps", "Do not run, simply dump the maps.").Bool() 48 | constantLabelsList = kingpin.Flag("constantLabels", "A list of label=value separated by comma(,). (DEPRECATED)").Default("").Envar("PG_EXPORTER_CONSTANT_LABELS").String() 49 | excludeDatabases = kingpin.Flag("exclude-databases", "A list of databases to remove when autoDiscoverDatabases is enabled (DEPRECATED)").Default("").Envar("PG_EXPORTER_EXCLUDE_DATABASES").String() 50 | includeDatabases = kingpin.Flag("include-databases", "A list of databases to include when autoDiscoverDatabases is enabled (DEPRECATED)").Default("").Envar("PG_EXPORTER_INCLUDE_DATABASES").String() 51 | metricPrefix = kingpin.Flag("metric-prefix", "A metric prefix can be used to have non-default (not \"pg\") prefixes for each of the metrics").Default("pg").Envar("PG_EXPORTER_METRIC_PREFIX").String() 52 | logger = promslog.NewNopLogger() 53 | ) 54 | 55 | // Metric name parts. 56 | const ( 57 | // Namespace for all metrics. 58 | namespace = "pg" 59 | // Subsystems. 60 | exporter = "exporter" 61 | // The name of the exporter. 62 | exporterName = "postgres_exporter" 63 | // Metric label used for static string data thats handy to send to Prometheus 64 | // e.g. version 65 | staticLabelName = "static" 66 | // Metric label used for server identification. 67 | serverLabelName = "server" 68 | ) 69 | 70 | func main() { 71 | kingpin.Version(version.Print(exporterName)) 72 | promslogConfig := &promslog.Config{} 73 | flag.AddFlags(kingpin.CommandLine, promslogConfig) 74 | kingpin.HelpFlag.Short('h') 75 | kingpin.Parse() 76 | logger = promslog.New(promslogConfig) 77 | 78 | if *onlyDumpMaps { 79 | dumpMaps() 80 | return 81 | } 82 | 83 | if err := c.ReloadConfig(*configFile, logger); err != nil { 84 | // This is not fatal, but it means that auth must be provided for every dsn. 85 | logger.Warn("Error loading config", "err", err) 86 | } 87 | 88 | dsns, err := getDataSources() 89 | if err != nil { 90 | logger.Error("Failed reading data sources", "err", err.Error()) 91 | os.Exit(1) 92 | } 93 | 94 | excludedDatabases := strings.Split(*excludeDatabases, ",") 95 | logger.Info("Excluded databases", "databases", fmt.Sprintf("%v", excludedDatabases)) 96 | 97 | if *queriesPath != "" { 98 | logger.Warn("The extended queries.yaml config is DEPRECATED", "file", *queriesPath) 99 | } 100 | 101 | if *autoDiscoverDatabases || *excludeDatabases != "" || *includeDatabases != "" { 102 | logger.Warn("Scraping additional databases via auto discovery is DEPRECATED") 103 | } 104 | 105 | if *constantLabelsList != "" { 106 | logger.Warn("Constant labels on all metrics is DEPRECATED") 107 | } 108 | 109 | opts := []ExporterOpt{ 110 | DisableDefaultMetrics(*disableDefaultMetrics), 111 | DisableSettingsMetrics(*disableSettingsMetrics), 112 | AutoDiscoverDatabases(*autoDiscoverDatabases), 113 | WithUserQueriesPath(*queriesPath), 114 | WithConstantLabels(*constantLabelsList), 115 | ExcludeDatabases(excludedDatabases), 116 | IncludeDatabases(*includeDatabases), 117 | } 118 | 119 | exporter := NewExporter(dsns, opts...) 120 | defer func() { 121 | exporter.servers.Close() 122 | }() 123 | 124 | prometheus.MustRegister(versioncollector.NewCollector(exporterName)) 125 | 126 | prometheus.MustRegister(exporter) 127 | 128 | // TODO(@sysadmind): Remove this with multi-target support. We are removing multiple DSN support 129 | dsn := "" 130 | if len(dsns) > 0 { 131 | dsn = dsns[0] 132 | } 133 | 134 | pe, err := collector.NewPostgresCollector( 135 | logger, 136 | excludedDatabases, 137 | dsn, 138 | []string{}, 139 | ) 140 | if err != nil { 141 | logger.Warn("Failed to create PostgresCollector", "err", err.Error()) 142 | } else { 143 | prometheus.MustRegister(pe) 144 | } 145 | 146 | http.Handle(*metricsPath, promhttp.Handler()) 147 | 148 | if *metricsPath != "/" && *metricsPath != "" { 149 | landingConfig := web.LandingConfig{ 150 | Name: "Postgres Exporter", 151 | Description: "Prometheus PostgreSQL server Exporter", 152 | Version: version.Info(), 153 | Links: []web.LandingLinks{ 154 | { 155 | Address: *metricsPath, 156 | Text: "Metrics", 157 | }, 158 | }, 159 | } 160 | landingPage, err := web.NewLandingPage(landingConfig) 161 | if err != nil { 162 | logger.Error("error creating landing page", "err", err) 163 | os.Exit(1) 164 | } 165 | http.Handle("/", landingPage) 166 | } 167 | 168 | http.HandleFunc("/probe", handleProbe(logger, excludedDatabases)) 169 | 170 | srv := &http.Server{} 171 | if err := web.ListenAndServe(srv, webConfig, logger); err != nil { 172 | logger.Error("Error running HTTP server", "err", err) 173 | os.Exit(1) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/pg_setting.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "math" 19 | "strconv" 20 | "strings" 21 | 22 | "github.com/prometheus/client_golang/prometheus" 23 | ) 24 | 25 | var ( 26 | settingUnits = []string{ 27 | "ms", "s", "min", "h", "d", 28 | "B", "kB", "MB", "GB", "TB", 29 | } 30 | ) 31 | 32 | // Query the pg_settings view containing runtime variables 33 | func querySettings(ch chan<- prometheus.Metric, server *Server) error { 34 | logger.Debug("Querying pg_setting view", "server", server) 35 | 36 | // pg_settings docs: https://www.postgresql.org/docs/current/static/view-pg-settings.html 37 | // 38 | // NOTE: If you add more vartypes here, you must update the supported 39 | // types in normaliseUnit() below 40 | query := "SELECT name, setting, COALESCE(unit, ''), short_desc, vartype FROM pg_settings WHERE vartype IN ('bool', 'integer', 'real') AND name != 'sync_commit_cancel_wait';" 41 | 42 | rows, err := server.db.Query(query) 43 | if err != nil { 44 | return fmt.Errorf("Error running query on database %q: %s %v", server, namespace, err) 45 | } 46 | defer rows.Close() // nolint: errcheck 47 | 48 | for rows.Next() { 49 | s := &pgSetting{} 50 | err = rows.Scan(&s.name, &s.setting, &s.unit, &s.shortDesc, &s.vartype) 51 | if err != nil { 52 | return fmt.Errorf("Error retrieving rows on %q: %s %v", server, namespace, err) 53 | } 54 | 55 | ch <- s.metric(server.labels) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // pgSetting is represents a PostgreSQL runtime variable as returned by the 62 | // pg_settings view. 63 | type pgSetting struct { 64 | name, setting, unit, shortDesc, vartype string 65 | } 66 | 67 | func (s *pgSetting) metric(labels prometheus.Labels) prometheus.Metric { 68 | var ( 69 | err error 70 | name = strings.ReplaceAll(strings.ReplaceAll(s.name, ".", "_"), "-", "_") 71 | unit = s.unit // nolint: ineffassign 72 | shortDesc = fmt.Sprintf("Server Parameter: %s", s.name) 73 | subsystem = "settings" 74 | val float64 75 | ) 76 | 77 | switch s.vartype { 78 | case "bool": 79 | if s.setting == "on" { 80 | val = 1 81 | } 82 | case "integer", "real": 83 | if val, unit, err = s.normaliseUnit(); err != nil { 84 | // Panic, since we should recognise all units 85 | // and don't want to silently exlude metrics 86 | panic(err) 87 | } 88 | 89 | if len(unit) > 0 { 90 | name = fmt.Sprintf("%s_%s", name, unit) 91 | shortDesc = fmt.Sprintf("%s [Units converted to %s.]", shortDesc, unit) 92 | } 93 | default: 94 | // Panic because we got a type we didn't ask for 95 | panic(fmt.Sprintf("Unsupported vartype %q", s.vartype)) 96 | } 97 | 98 | desc := newDesc(subsystem, name, shortDesc, labels) 99 | return prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, val) 100 | } 101 | 102 | // Removes units from any of the setting values. 103 | // This is mostly because of a irregularity regarding AWS RDS Aurora 104 | // https://github.com/prometheus-community/postgres_exporter/issues/619 105 | func (s *pgSetting) sanitizeValue() { 106 | for _, unit := range settingUnits { 107 | if strings.HasSuffix(s.setting, unit) { 108 | endPos := len(s.setting) - len(unit) - 1 109 | s.setting = s.setting[:endPos] 110 | return 111 | } 112 | } 113 | } 114 | 115 | // TODO: fix linter override 116 | // nolint: nakedret 117 | func (s *pgSetting) normaliseUnit() (val float64, unit string, err error) { 118 | s.sanitizeValue() 119 | 120 | val, err = strconv.ParseFloat(s.setting, 64) 121 | if err != nil { 122 | return val, unit, fmt.Errorf("Error converting setting %q value %q to float: %s", s.name, s.setting, err) 123 | } 124 | 125 | // Units defined in: https://www.postgresql.org/docs/current/static/config-setting.html 126 | switch s.unit { 127 | case "": 128 | return 129 | case "ms", "s", "min", "h", "d": 130 | unit = "seconds" 131 | case "B", "kB", "MB", "GB", "TB", "1kB", "2kB", "4kB", "8kB", "16kB", "32kB", "64kB", "16MB", "32MB", "64MB": 132 | unit = "bytes" 133 | default: 134 | err = fmt.Errorf("unknown unit for runtime variable: %q", s.unit) 135 | return 136 | } 137 | 138 | // -1 is special, don't modify the value 139 | if val == -1 { 140 | return 141 | } 142 | 143 | switch s.unit { 144 | case "ms": 145 | val /= 1000 146 | case "min": 147 | val *= 60 148 | case "h": 149 | val *= 60 * 60 150 | case "d": 151 | val *= 60 * 60 * 24 152 | case "kB": 153 | val *= math.Pow(2, 10) 154 | case "MB": 155 | val *= math.Pow(2, 20) 156 | case "GB": 157 | val *= math.Pow(2, 30) 158 | case "TB": 159 | val *= math.Pow(2, 40) 160 | case "1kB": 161 | val *= math.Pow(2, 10) 162 | case "2kB": 163 | val *= math.Pow(2, 11) 164 | case "4kB": 165 | val *= math.Pow(2, 12) 166 | case "8kB": 167 | val *= math.Pow(2, 13) 168 | case "16kB": 169 | val *= math.Pow(2, 14) 170 | case "32kB": 171 | val *= math.Pow(2, 15) 172 | case "64kB": 173 | val *= math.Pow(2, 16) 174 | case "16MB": 175 | val *= math.Pow(2, 24) 176 | case "32MB": 177 | val *= math.Pow(2, 25) 178 | case "64MB": 179 | val *= math.Pow(2, 26) 180 | } 181 | 182 | return 183 | } 184 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/postgres_exporter_integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | // These are specialized integration tests. We only build them when we're doing 15 | // a lot of additional work to keep the external docker environment they require 16 | // working. 17 | //go:build integration 18 | // +build integration 19 | 20 | package main 21 | 22 | import ( 23 | "fmt" 24 | "os" 25 | "strings" 26 | "testing" 27 | 28 | _ "github.com/lib/pq" 29 | "github.com/prometheus/client_golang/prometheus" 30 | . "gopkg.in/check.v1" 31 | ) 32 | 33 | // Hook up gocheck into the "go test" runner. 34 | func Test(t *testing.T) { TestingT(t) } 35 | 36 | type IntegrationSuite struct { 37 | e *Exporter 38 | } 39 | 40 | var _ = Suite(&IntegrationSuite{}) 41 | 42 | func (s *IntegrationSuite) SetUpSuite(c *C) { 43 | dsn := os.Getenv("DATA_SOURCE_NAME") 44 | c.Assert(dsn, Not(Equals), "") 45 | 46 | exporter := NewExporter(strings.Split(dsn, ",")) 47 | c.Assert(exporter, NotNil) 48 | // Assign the exporter to the suite 49 | s.e = exporter 50 | 51 | prometheus.MustRegister(exporter) 52 | } 53 | 54 | // TODO: it would be nice if cu didn't mostly just recreate the scrape function 55 | func (s *IntegrationSuite) TestAllNamespacesReturnResults(c *C) { 56 | // Setup a dummy channel to consume metrics 57 | ch := make(chan prometheus.Metric, 100) 58 | go func() { 59 | for range ch { 60 | } 61 | }() 62 | 63 | for _, dsn := range s.e.dsn { 64 | // Open a database connection 65 | server, err := NewServer(dsn) 66 | c.Assert(server, NotNil) 67 | c.Assert(err, IsNil) 68 | 69 | // Do a version update 70 | err = s.e.checkMapVersions(ch, server) 71 | c.Assert(err, IsNil) 72 | 73 | err = querySettings(ch, server) 74 | if !c.Check(err, Equals, nil) { 75 | fmt.Println("## ERRORS FOUND") 76 | fmt.Println(err) 77 | } 78 | 79 | // This should never happen in our test cases. 80 | errMap := queryNamespaceMappings(ch, server) 81 | if !c.Check(len(errMap), Equals, 0) { 82 | fmt.Println("## NAMESPACE ERRORS FOUND") 83 | for namespace, err := range errMap { 84 | fmt.Println(namespace, ":", err) 85 | } 86 | } 87 | server.Close() 88 | } 89 | } 90 | 91 | // TestInvalidDsnDoesntCrash tests that specifying an invalid DSN doesn't crash 92 | // the exporter. Related to https://github.com/prometheus-community/postgres_exporter/issues/93 93 | // although not a replication of the scenario. 94 | func (s *IntegrationSuite) TestInvalidDsnDoesntCrash(c *C) { 95 | // Setup a dummy channel to consume metrics 96 | ch := make(chan prometheus.Metric, 100) 97 | go func() { 98 | for range ch { 99 | } 100 | }() 101 | 102 | // Send a bad DSN 103 | exporter := NewExporter([]string{"invalid dsn"}) 104 | c.Assert(exporter, NotNil) 105 | exporter.scrape(ch) 106 | 107 | // Send a DSN to a non-listening port. 108 | exporter = NewExporter([]string{"postgresql://nothing:nothing@127.0.0.1:1/nothing"}) 109 | c.Assert(exporter, NotNil) 110 | exporter.scrape(ch) 111 | } 112 | 113 | // TestUnknownMetricParsingDoesntCrash deliberately deletes all the column maps out 114 | // of an exporter to test that the default metric handling code can cope with unknown columns. 115 | func (s *IntegrationSuite) TestUnknownMetricParsingDoesntCrash(c *C) { 116 | // Setup a dummy channel to consume metrics 117 | ch := make(chan prometheus.Metric, 100) 118 | go func() { 119 | for range ch { 120 | } 121 | }() 122 | 123 | dsn := os.Getenv("DATA_SOURCE_NAME") 124 | c.Assert(dsn, Not(Equals), "") 125 | 126 | exporter := NewExporter(strings.Split(dsn, ",")) 127 | c.Assert(exporter, NotNil) 128 | 129 | // Convert the default maps into a list of empty maps. 130 | emptyMaps := make(map[string]intermediateMetricMap, 0) 131 | for k := range exporter.builtinMetricMaps { 132 | emptyMaps[k] = intermediateMetricMap{ 133 | map[string]ColumnMapping{}, 134 | true, 135 | 0, 136 | } 137 | } 138 | exporter.builtinMetricMaps = emptyMaps 139 | 140 | // scrape the exporter and make sure it works 141 | exporter.scrape(ch) 142 | } 143 | 144 | // TestExtendQueriesDoesntCrash tests that specifying extend.query-path doesn't 145 | // crash. 146 | func (s *IntegrationSuite) TestExtendQueriesDoesntCrash(c *C) { 147 | // Setup a dummy channel to consume metrics 148 | ch := make(chan prometheus.Metric, 100) 149 | go func() { 150 | for range ch { 151 | } 152 | }() 153 | 154 | dsn := os.Getenv("DATA_SOURCE_NAME") 155 | c.Assert(dsn, Not(Equals), "") 156 | 157 | exporter := NewExporter( 158 | strings.Split(dsn, ","), 159 | WithUserQueriesPath("../user_queries_test.yaml"), 160 | ) 161 | c.Assert(exporter, NotNil) 162 | 163 | // scrape the exporter and make sure it works 164 | exporter.scrape(ch) 165 | } 166 | 167 | func (s *IntegrationSuite) TestAutoDiscoverDatabases(c *C) { 168 | dsn := os.Getenv("DATA_SOURCE_NAME") 169 | 170 | exporter := NewExporter( 171 | strings.Split(dsn, ","), 172 | ) 173 | c.Assert(exporter, NotNil) 174 | 175 | dsns := exporter.discoverDatabaseDSNs() 176 | 177 | c.Assert(len(dsns), Equals, 2) 178 | } 179 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/probe.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "log/slog" 19 | "net/http" 20 | 21 | "github.com/prometheus-community/postgres_exporter/collector" 22 | "github.com/prometheus-community/postgres_exporter/config" 23 | "github.com/prometheus/client_golang/prometheus" 24 | "github.com/prometheus/client_golang/prometheus/promhttp" 25 | ) 26 | 27 | func handleProbe(logger *slog.Logger, excludeDatabases []string) http.HandlerFunc { 28 | return func(w http.ResponseWriter, r *http.Request) { 29 | ctx := r.Context() 30 | conf := c.GetConfig() 31 | params := r.URL.Query() 32 | target := params.Get("target") 33 | if target == "" { 34 | http.Error(w, "target is required", http.StatusBadRequest) 35 | return 36 | } 37 | var authModule config.AuthModule 38 | authModuleName := params.Get("auth_module") 39 | if authModuleName == "" { 40 | logger.Info("no auth_module specified, using default") 41 | } else { 42 | var ok bool 43 | authModule, ok = conf.AuthModules[authModuleName] 44 | if !ok { 45 | http.Error(w, fmt.Sprintf("auth_module %s not found", authModuleName), http.StatusBadRequest) 46 | return 47 | } 48 | if authModule.UserPass.Username == "" || authModule.UserPass.Password == "" { 49 | http.Error(w, fmt.Sprintf("auth_module %s has no username or password", authModuleName), http.StatusBadRequest) 50 | return 51 | } 52 | } 53 | 54 | dsn, err := authModule.ConfigureTarget(target) 55 | if err != nil { 56 | logger.Error("failed to configure target", "err", err) 57 | http.Error(w, fmt.Sprintf("could not configure dsn for target: %v", err), http.StatusBadRequest) 58 | return 59 | } 60 | 61 | // TODO(@sysadmind): Timeout 62 | 63 | tl := logger.With("target", target) 64 | 65 | registry := prometheus.NewRegistry() 66 | 67 | opts := []ExporterOpt{ 68 | DisableDefaultMetrics(*disableDefaultMetrics), 69 | DisableSettingsMetrics(*disableSettingsMetrics), 70 | AutoDiscoverDatabases(*autoDiscoverDatabases), 71 | WithUserQueriesPath(*queriesPath), 72 | WithConstantLabels(*constantLabelsList), 73 | ExcludeDatabases(excludeDatabases), 74 | IncludeDatabases(*includeDatabases), 75 | } 76 | 77 | dsns := []string{dsn.GetConnectionString()} 78 | exporter := NewExporter(dsns, opts...) 79 | defer func() { 80 | exporter.servers.Close() 81 | }() 82 | registry.MustRegister(exporter) 83 | 84 | // Run the probe 85 | pc, err := collector.NewProbeCollector(tl, excludeDatabases, registry, dsn) 86 | if err != nil { 87 | logger.Error("Error creating probe collector", "err", err) 88 | http.Error(w, err.Error(), http.StatusInternalServerError) 89 | return 90 | } 91 | 92 | // Cleanup underlying connections to prevent connection leaks 93 | defer pc.Close() 94 | 95 | // TODO(@sysadmind): Remove the registry.MustRegister() call below and instead handle the collection here. That will allow 96 | // for the passing of context, handling of timeouts, and more control over the collection. 97 | // The current NewProbeCollector() implementation relies on the MustNewConstMetric() call to create the metrics which is not 98 | // ideal to use without the registry.MustRegister() call. 99 | _ = ctx 100 | 101 | registry.MustRegister(pc) 102 | 103 | // TODO check success, etc 104 | h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) 105 | h.ServeHTTP(w, r) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "database/sql" 18 | "fmt" 19 | "sync" 20 | "time" 21 | 22 | "github.com/blang/semver/v4" 23 | "github.com/prometheus/client_golang/prometheus" 24 | ) 25 | 26 | // Server describes a connection to Postgres. 27 | // Also it contains metrics map and query overrides. 28 | type Server struct { 29 | db *sql.DB 30 | labels prometheus.Labels 31 | master bool 32 | runonserver string 33 | 34 | // Last version used to calculate metric map. If mismatch on scrape, 35 | // then maps are recalculated. 36 | lastMapVersion semver.Version 37 | // Currently active metric map 38 | metricMap map[string]MetricMapNamespace 39 | // Currently active query overrides 40 | queryOverrides map[string]string 41 | mappingMtx sync.RWMutex 42 | // Currently cached metrics 43 | metricCache map[string]cachedMetrics 44 | cacheMtx sync.Mutex 45 | } 46 | 47 | // ServerOpt configures a server. 48 | type ServerOpt func(*Server) 49 | 50 | // ServerWithLabels configures a set of labels. 51 | func ServerWithLabels(labels prometheus.Labels) ServerOpt { 52 | return func(s *Server) { 53 | for k, v := range labels { 54 | s.labels[k] = v 55 | } 56 | } 57 | } 58 | 59 | // NewServer establishes a new connection using DSN. 60 | func NewServer(dsn string, opts ...ServerOpt) (*Server, error) { 61 | fingerprint, err := parseFingerprint(dsn) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | db, err := sql.Open("postgres", dsn) 67 | if err != nil { 68 | return nil, err 69 | } 70 | db.SetMaxOpenConns(1) 71 | db.SetMaxIdleConns(1) 72 | 73 | logger.Info("Established new database connection", "fingerprint", fingerprint) 74 | 75 | s := &Server{ 76 | db: db, 77 | master: false, 78 | labels: prometheus.Labels{ 79 | serverLabelName: fingerprint, 80 | }, 81 | metricCache: make(map[string]cachedMetrics), 82 | } 83 | 84 | for _, opt := range opts { 85 | opt(s) 86 | } 87 | 88 | return s, nil 89 | } 90 | 91 | // Close disconnects from Postgres. 92 | func (s *Server) Close() error { 93 | return s.db.Close() 94 | } 95 | 96 | // Ping checks connection availability and possibly invalidates the connection if it fails. 97 | func (s *Server) Ping() error { 98 | if err := s.db.Ping(); err != nil { 99 | if cerr := s.Close(); cerr != nil { 100 | logger.Error("Error while closing non-pinging DB connection", "server", s, "err", cerr) 101 | } 102 | return err 103 | } 104 | return nil 105 | } 106 | 107 | // String returns server's fingerprint. 108 | func (s *Server) String() string { 109 | return s.labels[serverLabelName] 110 | } 111 | 112 | // Scrape loads metrics. 113 | func (s *Server) Scrape(ch chan<- prometheus.Metric, disableSettingsMetrics bool) error { 114 | s.mappingMtx.RLock() 115 | defer s.mappingMtx.RUnlock() 116 | 117 | var err error 118 | 119 | if !disableSettingsMetrics && s.master { 120 | if err = querySettings(ch, s); err != nil { 121 | err = fmt.Errorf("error retrieving settings: %s", err) 122 | return err 123 | } 124 | } 125 | 126 | errMap := queryNamespaceMappings(ch, s) 127 | if len(errMap) == 0 { 128 | return nil 129 | } 130 | err = fmt.Errorf("queryNamespaceMappings errors encountered") 131 | for namespace, errStr := range errMap { 132 | err = fmt.Errorf("%s, namespace: %s error: %s", err, namespace, errStr) 133 | } 134 | 135 | return err 136 | } 137 | 138 | // Servers contains a collection of servers to Postgres. 139 | type Servers struct { 140 | m sync.Mutex 141 | servers map[string]*Server 142 | opts []ServerOpt 143 | } 144 | 145 | // NewServers creates a collection of servers to Postgres. 146 | func NewServers(opts ...ServerOpt) *Servers { 147 | return &Servers{ 148 | servers: make(map[string]*Server), 149 | opts: opts, 150 | } 151 | } 152 | 153 | // GetServer returns established connection from a collection. 154 | func (s *Servers) GetServer(dsn string) (*Server, error) { 155 | s.m.Lock() 156 | defer s.m.Unlock() 157 | var err error 158 | var ok bool 159 | errCount := 0 // start at zero because we increment before doing work 160 | retries := 1 161 | var server *Server 162 | for { 163 | if errCount++; errCount > retries { 164 | return nil, err 165 | } 166 | server, ok = s.servers[dsn] 167 | if !ok { 168 | server, err = NewServer(dsn, s.opts...) 169 | if err != nil { 170 | time.Sleep(time.Duration(errCount) * time.Second) 171 | continue 172 | } 173 | s.servers[dsn] = server 174 | } 175 | if err = server.Ping(); err != nil { 176 | delete(s.servers, dsn) 177 | time.Sleep(time.Duration(errCount) * time.Second) 178 | continue 179 | } 180 | break 181 | } 182 | return server, nil 183 | } 184 | 185 | // Close disconnects from all known servers. 186 | func (s *Servers) Close() { 187 | s.m.Lock() 188 | defer s.m.Unlock() 189 | for _, server := range s.servers { 190 | if err := server.Close(); err != nil { 191 | logger.Error("Failed to close connection", "server", server, "err", err) 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/docker-postgres-replication/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:11 2 | MAINTAINER Daniel Dent (https://www.danieldent.com) 3 | ENV PG_MAX_WAL_SENDERS 8 4 | ENV PG_WAL_KEEP_SEGMENTS 8 5 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y inetutils-ping 6 | COPY setup-replication.sh /docker-entrypoint-initdb.d/ 7 | COPY docker-entrypoint.sh /docker-entrypoint.sh 8 | RUN chmod +x /docker-entrypoint-initdb.d/setup-replication.sh /docker-entrypoint.sh 9 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/docker-postgres-replication/Dockerfile.p2: -------------------------------------------------------------------------------- 1 | FROM postgres:{{VERSION}} 2 | MAINTAINER Daniel Dent (https://www.danieldent.com) 3 | ENV PG_MAX_WAL_SENDERS 8 4 | ENV PG_WAL_KEEP_SEGMENTS 8 5 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y inetutils-ping 6 | COPY setup-replication.sh /docker-entrypoint-initdb.d/ 7 | COPY docker-entrypoint.sh /docker-entrypoint.sh 8 | RUN chmod +x /docker-entrypoint-initdb.d/setup-replication.sh /docker-entrypoint.sh 9 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/docker-postgres-replication/README.md: -------------------------------------------------------------------------------- 1 | # Replicated postgres cluster in docker. 2 | 3 | Upstream is forked from https://github.com/DanielDent/docker-postgres-replication 4 | 5 | My version lives at https://github.com/wrouesnel/docker-postgres-replication 6 | 7 | This very simple docker-compose file lets us stand up a replicated postgres 8 | cluster so we can test streaming. 9 | 10 | # TODO: 11 | Pull in p2 and template the Dockerfile so we can test multiple versions. 12 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/docker-postgres-replication/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '2' 3 | 4 | services: 5 | pg-master: 6 | build: '.' 7 | image: 'danieldent/postgres-replication' 8 | restart: 'always' 9 | environment: 10 | POSTGRES_USER: 'postgres' 11 | POSTGRES_PASSWORD: 'postgres' 12 | PGDATA: '/var/lib/postgresql/data/pgdata' 13 | volumes: 14 | - '/var/lib/postgresql/data' 15 | expose: 16 | - '5432' 17 | 18 | pg-slave: 19 | build: '.' 20 | image: 'danieldent/postgres-replication' 21 | restart: 'always' 22 | environment: 23 | POSTGRES_USER: 'postgres' 24 | POSTGRES_PASSWORD: 'postgres' 25 | PGDATA: '/var/lib/postgresql/data/pgdata' 26 | REPLICATE_FROM: 'pg-master' 27 | volumes: 28 | - '/var/lib/postgresql/data' 29 | expose: 30 | - '5432' 31 | links: 32 | - 'pg-master' 33 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/docker-postgres-replication/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Backwards compatibility for old variable names (deprecated) 4 | if [ "x$PGUSER" != "x" ]; then 5 | POSTGRES_USER=$PGUSER 6 | fi 7 | if [ "x$PGPASSWORD" != "x" ]; then 8 | POSTGRES_PASSWORD=$PGPASSWORD 9 | fi 10 | 11 | # Forwards-compatibility for old variable names (pg_basebackup uses them) 12 | if [ "x$PGPASSWORD" = "x" ]; then 13 | export PGPASSWORD=$POSTGRES_PASSWORD 14 | fi 15 | 16 | # Based on official postgres package's entrypoint script (https://hub.docker.com/_/postgres/) 17 | # Modified to be able to set up a slave. The docker-entrypoint-initdb.d hook provided is inadequate. 18 | 19 | set -e 20 | 21 | if [ "${1:0:1}" = '-' ]; then 22 | set -- postgres "$@" 23 | fi 24 | 25 | if [ "$1" = 'postgres' ]; then 26 | mkdir -p "$PGDATA" 27 | chmod 700 "$PGDATA" 28 | chown -R postgres "$PGDATA" 29 | 30 | mkdir -p /run/postgresql 31 | chmod g+s /run/postgresql 32 | chown -R postgres /run/postgresql 33 | 34 | # look specifically for PG_VERSION, as it is expected in the DB dir 35 | if [ ! -s "$PGDATA/PG_VERSION" ]; then 36 | if [ "x$REPLICATE_FROM" == "x" ]; then 37 | eval "gosu postgres initdb $POSTGRES_INITDB_ARGS" 38 | else 39 | until /bin/ping -c 1 -W 1 ${REPLICATE_FROM} 40 | do 41 | echo "Waiting for master to ping..." 42 | sleep 1s 43 | done 44 | until gosu postgres pg_basebackup -h ${REPLICATE_FROM} -D ${PGDATA} -U ${POSTGRES_USER} -vP -w 45 | do 46 | echo "Waiting for master to connect..." 47 | sleep 1s 48 | done 49 | fi 50 | 51 | # check password first so we can output the warning before postgres 52 | # messes it up 53 | if [ ! -z "$POSTGRES_PASSWORD" ]; then 54 | pass="PASSWORD '$POSTGRES_PASSWORD'" 55 | authMethod=md5 56 | else 57 | # The - option suppresses leading tabs but *not* spaces. :) 58 | cat >&2 <<-'EOWARN' 59 | **************************************************** 60 | WARNING: No password has been set for the database. 61 | This will allow anyone with access to the 62 | Postgres port to access your database. In 63 | Docker's default configuration, this is 64 | effectively any other container on the same 65 | system. 66 | 67 | Use "-e POSTGRES_PASSWORD=password" to set 68 | it in "docker run". 69 | **************************************************** 70 | EOWARN 71 | 72 | pass= 73 | authMethod=trust 74 | fi 75 | 76 | if [ "x$REPLICATE_FROM" == "x" ]; then 77 | 78 | { echo; echo "host replication all 0.0.0.0/0 $authMethod"; } | gosu postgres tee -a "$PGDATA/pg_hba.conf" > /dev/null 79 | { echo; echo "host all all 0.0.0.0/0 $authMethod"; } | gosu postgres tee -a "$PGDATA/pg_hba.conf" > /dev/null 80 | 81 | # internal start of server in order to allow set-up using psql-client 82 | # does not listen on external TCP/IP and waits until start finishes 83 | gosu postgres pg_ctl -D "$PGDATA" \ 84 | -o "-c listen_addresses='localhost'" \ 85 | -w start 86 | 87 | : ${POSTGRES_USER:=postgres} 88 | : ${POSTGRES_DB:=$POSTGRES_USER} 89 | export POSTGRES_USER POSTGRES_DB 90 | 91 | psql=( "psql" "-v" "ON_ERROR_STOP=1" ) 92 | 93 | if [ "$POSTGRES_DB" != 'postgres' ]; then 94 | "${psql[@]}" --username postgres <<-EOSQL 95 | CREATE DATABASE "$POSTGRES_DB" ; 96 | EOSQL 97 | echo 98 | fi 99 | 100 | if [ "$POSTGRES_USER" = 'postgres' ]; then 101 | op='ALTER' 102 | else 103 | op='CREATE' 104 | fi 105 | "${psql[@]}" --username postgres <<-EOSQL 106 | $op USER "$POSTGRES_USER" WITH SUPERUSER $pass ; 107 | EOSQL 108 | echo 109 | 110 | fi 111 | 112 | psql+=( --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" ) 113 | 114 | echo 115 | for f in /docker-entrypoint-initdb.d/*; do 116 | case "$f" in 117 | *.sh) echo "$0: running $f"; . "$f" ;; 118 | *.sql) echo "$0: running $f"; "${psql[@]}" < "$f"; echo ;; 119 | *.sql.gz) echo "$0: running $f"; gunzip -c "$f" | "${psql[@]}"; echo ;; 120 | *) echo "$0: ignoring $f" ;; 121 | esac 122 | echo 123 | done 124 | 125 | if [ "x$REPLICATE_FROM" == "x" ]; then 126 | gosu postgres pg_ctl -D "$PGDATA" -m fast -w stop 127 | fi 128 | 129 | echo 130 | echo 'PostgreSQL init process complete; ready for start up.' 131 | echo 132 | fi 133 | 134 | # We need this health check so we know when it's started up. 135 | touch /tmp/.postgres_init_complete 136 | 137 | exec gosu postgres "$@" 138 | fi 139 | 140 | exec "$@" 141 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/docker-postgres-replication/setup-replication.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "x$REPLICATE_FROM" == "x" ]; then 4 | 5 | cat >> ${PGDATA}/postgresql.conf < ${PGDATA}/recovery.conf <&2 21 | echo "Test Binary: $test_binary" 1>&2 22 | 23 | [ -z "$postgres_exporter" ] && echo "Missing exporter binary" && exit 1 24 | [ -z "$test_binary" ] && echo "Missing test binary" && exit 1 25 | 26 | cd "$DIR" || exit 1 27 | 28 | VERSIONS=( \ 29 | 9.4 \ 30 | 9.5 \ 31 | 9.6 \ 32 | 10 \ 33 | 11 \ 34 | ) 35 | 36 | wait_for_postgres(){ 37 | local container=$1 38 | local ip=$2 39 | local port=$3 40 | if [ -z "$ip" ]; then 41 | echo "No IP specified." 1>&2 42 | exit 1 43 | fi 44 | 45 | if [ -z "$port" ]; then 46 | echo "No port specified." 1>&2 47 | exit 1 48 | fi 49 | 50 | local wait_start 51 | wait_start=$(date +%s) || exit 1 52 | echo "Waiting for postgres to start listening..." 53 | while ! docker exec "$container" pg_isready --host="$ip" --port="$port" &> /dev/null; do 54 | if [ $(( $(date +%s) - wait_start )) -gt "$TIMEOUT" ]; then 55 | echo "Timed out waiting for postgres to start!" 1>&2 56 | exit 1 57 | fi 58 | sleep 1 59 | done 60 | echo "Postgres is online at $ip:$port" 61 | } 62 | 63 | wait_for_exporter() { 64 | local wait_start 65 | wait_start=$(date +%s) || exit 1 66 | echo "Waiting for exporter to start..." 67 | while ! nc -z localhost "$exporter_port" ; do 68 | if [ $(( $(date +%s) - wait_start )) -gt "$TIMEOUT" ]; then 69 | echo "Timed out waiting for exporter!" 1>&2 70 | exit 1 71 | fi 72 | sleep 1 73 | done 74 | echo "Exporter is online at localhost:$exporter_port" 75 | } 76 | 77 | smoketest_postgres() { 78 | local version=$1 79 | local CONTAINER_NAME=postgres_exporter-test-smoke 80 | local TIMEOUT=30 81 | local IMAGE_NAME=postgres 82 | 83 | local CUR_IMAGE=$IMAGE_NAME:$version 84 | 85 | echo "#######################" 86 | echo "Standalone Postgres $version" 87 | echo "#######################" 88 | local docker_cmd="docker run -d -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD $CUR_IMAGE" 89 | echo "Docker Cmd: $docker_cmd" 90 | 91 | CONTAINER_NAME=$($docker_cmd) 92 | standalone_ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $CONTAINER_NAME) 93 | # shellcheck disable=SC2064 94 | trap "docker logs $CONTAINER_NAME ; docker kill $CONTAINER_NAME ; docker rm -v $CONTAINER_NAME; exit 1" EXIT INT TERM 95 | wait_for_postgres "$CONTAINER_NAME" "$standalone_ip" 5432 96 | 97 | # Run the test binary. 98 | DATA_SOURCE_NAME="postgresql://postgres:$POSTGRES_PASSWORD@$standalone_ip:5432/?sslmode=disable" $test_binary || exit $? 99 | 100 | # Extract a raw metric list. 101 | DATA_SOURCE_NAME="postgresql://postgres:$POSTGRES_PASSWORD@$standalone_ip:5432/?sslmode=disable" $postgres_exporter \ 102 | --log.level=debug --web.listen-address=:$exporter_port & 103 | exporter_pid=$! 104 | # shellcheck disable=SC2064 105 | trap "docker logs $CONTAINER_NAME ; docker kill $CONTAINER_NAME ; docker rm -v $CONTAINER_NAME; kill $exporter_pid; exit 1" EXIT INT TERM 106 | wait_for_exporter 107 | 108 | # Dump the metrics to a file. 109 | if ! wget -q -O - http://localhost:$exporter_port/metrics 1> "$METRICS_DIR/.metrics.single.$version.prom" ; then 110 | echo "Failed on postgres $version (standalone $DOCKER_IMAGE)" 1>&2 111 | kill $exporter_pid 112 | exit 1 113 | fi 114 | 115 | # HACK test: check pg_up is a 1 - TODO: expand integration tests to include metric consumption 116 | if ! grep 'pg_up.* 1' $METRICS_DIR/.metrics.single.$version.prom ; then 117 | echo "pg_up metric was not 1 despite exporter and database being up" 118 | kill $exporter_pid 119 | exit 1 120 | fi 121 | 122 | kill $exporter_pid 123 | docker kill "$CONTAINER_NAME" 124 | docker rm -v "$CONTAINER_NAME" 125 | trap - EXIT INT TERM 126 | 127 | echo "#######################" 128 | echo "Replicated Postgres $version" 129 | echo "#######################" 130 | old_pwd=$(pwd) 131 | cd docker-postgres-replication || exit 1 132 | 133 | if ! VERSION="$version" p2 -t Dockerfile.p2 -o Dockerfile ; then 134 | echo "Templating failed" 1>&2 135 | exit 1 136 | fi 137 | trap "docker-compose logs; docker-compose down ; docker-compose rm -v; exit 1" EXIT INT TERM 138 | local compose_cmd="POSTGRES_PASSWORD=$POSTGRES_PASSWORD docker-compose up -d --force-recreate --build" 139 | echo "Compose Cmd: $compose_cmd" 140 | eval "$compose_cmd" 141 | 142 | master_container=$(docker-compose ps -q pg-master) 143 | slave_container=$(docker-compose ps -q pg-slave) 144 | master_ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$master_container") 145 | slave_ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$slave_container") 146 | echo "Got master IP: $master_ip" 147 | wait_for_postgres "$master_container" "$master_ip" 5432 148 | wait_for_postgres "$slave_container" "$slave_ip" 5432 149 | 150 | DATA_SOURCE_NAME="postgresql://postgres:$POSTGRES_PASSWORD@$master_ip:5432/?sslmode=disable" $test_binary || exit $? 151 | 152 | DATA_SOURCE_NAME="postgresql://postgres:$POSTGRES_PASSWORD@$master_ip:5432/?sslmode=disable" $postgres_exporter \ 153 | --log.level=debug --web.listen-address=:$exporter_port & 154 | exporter_pid=$! 155 | # shellcheck disable=SC2064 156 | trap "docker-compose logs; docker-compose down ; docker-compose rm -v ; kill $exporter_pid; exit 1" EXIT INT TERM 157 | wait_for_exporter 158 | 159 | if ! wget -q -O - http://localhost:$exporter_port/metrics 1> "$METRICS_DIR/.metrics.replicated.$version.prom" ; then 160 | echo "Failed on postgres $version (replicated $DOCKER_IMAGE)" 1>&2 161 | exit 1 162 | fi 163 | 164 | kill $exporter_pid 165 | docker-compose down 166 | docker-compose rm -v 167 | trap - EXIT INT TERM 168 | 169 | cd "$old_pwd" || exit 1 170 | } 171 | 172 | # Start pulling the docker images in advance 173 | for version in "${VERSIONS[@]}"; do 174 | docker pull "postgres:$version" > /dev/null & 175 | done 176 | 177 | for version in "${VERSIONS[@]}"; do 178 | echo "Testing postgres version $version" 179 | smoketest_postgres "$version" 180 | done 181 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/user_queries_ok.yaml: -------------------------------------------------------------------------------- 1 | pg_locks_mode: 2 | query: "WITH q_locks AS (select * from pg_locks where pid != pg_backend_pid() and database = (select oid from pg_database where datname = current_database())) SELECT (select current_database()) as datname, 3 | lockmodes AS tag_lockmode, coalesce((select count(*) FROM q_locks WHERE mode = lockmodes), 0) AS count FROM 4 | unnest('{AccessShareLock, ExclusiveLock, RowShareLock, RowExclusiveLock, ShareLock, ShareRowExclusiveLock, AccessExclusiveLock, ShareUpdateExclusiveLock}'::text[]) lockmodes;" 5 | metrics: 6 | - datname: 7 | usage: "LABEL" 8 | description: "Database name" 9 | - tag_lockmode: 10 | usage: "LABEL" 11 | description: "Lock type" 12 | - count: 13 | usage: "GAUGE" 14 | description: "Number of lock" 15 | pg_wal: 16 | query: "select current_database() as datname, case when pg_is_in_recovery() = false then pg_xlog_location_diff(pg_current_xlog_location(), '0/0')::int8 else pg_xlog_location_diff(pg_last_xlog_replay_location(), '0/0')::int8 end as xlog_location_b;" 17 | metrics: 18 | - datname: 19 | usage: "LABEL" 20 | description: "Database name" 21 | - xlog_location_b: 22 | usage: "COUNTER" 23 | description: "current transaction log write location" 24 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/user_queries_test.yaml: -------------------------------------------------------------------------------- 1 | random: 2 | query: | 3 | WITH data AS (SELECT floor(random()*10) AS d FROM generate_series(1,100)), 4 | metrics AS (SELECT SUM(d) AS sum, COUNT(*) AS count FROM data), 5 | buckets AS (SELECT le, SUM(CASE WHEN d <= le THEN 1 ELSE 0 END) AS d 6 | FROM data, UNNEST(ARRAY[1, 2, 4, 8]) AS le GROUP BY le) 7 | SELECT 8 | sum AS histogram_sum, 9 | count AS histogram_count, 10 | ARRAY_AGG(le) AS histogram, 11 | ARRAY_AGG(d) AS histogram_bucket, 12 | ARRAY_AGG(le) AS missing, 13 | ARRAY_AGG(le) AS missing_sum, 14 | ARRAY_AGG(d) AS missing_sum_bucket, 15 | ARRAY_AGG(le) AS missing_count, 16 | ARRAY_AGG(d) AS missing_count_bucket, 17 | sum AS missing_count_sum, 18 | ARRAY_AGG(le) AS unexpected_sum, 19 | ARRAY_AGG(d) AS unexpected_sum_bucket, 20 | 'data' AS unexpected_sum_sum, 21 | ARRAY_AGG(le) AS unexpected_count, 22 | ARRAY_AGG(d) AS unexpected_count_bucket, 23 | sum AS unexpected_count_sum, 24 | 'nan'::varchar AS unexpected_count_count, 25 | ARRAY_AGG(le) AS unexpected_bytes, 26 | ARRAY_AGG(d) AS unexpected_bytes_bucket, 27 | sum AS unexpected_bytes_sum, 28 | 'nan'::bytea AS unexpected_bytes_count 29 | FROM metrics, buckets GROUP BY 1,2 30 | metrics: 31 | - histogram: 32 | usage: "HISTOGRAM" 33 | description: "Random data" 34 | - missing: 35 | usage: "HISTOGRAM" 36 | description: "nonfatal error" 37 | - missing_sum: 38 | usage: "HISTOGRAM" 39 | description: "nonfatal error" 40 | - missing_count: 41 | usage: "HISTOGRAM" 42 | description: "nonfatal error" 43 | - unexpected_sum: 44 | usage: "HISTOGRAM" 45 | description: "nonfatal error" 46 | - unexpected_count: 47 | usage: "HISTOGRAM" 48 | description: "nonfatal error" 49 | - unexpected_bytes: 50 | usage: "HISTOGRAM" 51 | description: "nonfatal error" 52 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/username_file: -------------------------------------------------------------------------------- 1 | custom_username$&+,/:;=?@ 2 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/tests/userpass_file: -------------------------------------------------------------------------------- 1 | custom_password$&+,/:;=?@ 2 | -------------------------------------------------------------------------------- /cmd/postgres_exporter/util.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "math" 19 | "net/url" 20 | "strconv" 21 | "strings" 22 | "time" 23 | 24 | "github.com/lib/pq" 25 | ) 26 | 27 | func contains(a []string, x string) bool { 28 | for _, n := range a { 29 | if x == n { 30 | return true 31 | } 32 | } 33 | return false 34 | } 35 | 36 | // convert a string to the corresponding ColumnUsage 37 | func stringToColumnUsage(s string) (ColumnUsage, error) { 38 | var u ColumnUsage 39 | var err error 40 | switch s { 41 | case "DISCARD": 42 | u = DISCARD 43 | 44 | case "LABEL": 45 | u = LABEL 46 | 47 | case "COUNTER": 48 | u = COUNTER 49 | 50 | case "GAUGE": 51 | u = GAUGE 52 | 53 | case "HISTOGRAM": 54 | u = HISTOGRAM 55 | 56 | case "MAPPEDMETRIC": 57 | u = MAPPEDMETRIC 58 | 59 | case "DURATION": 60 | u = DURATION 61 | 62 | default: 63 | err = fmt.Errorf("wrong ColumnUsage given : %s", s) 64 | } 65 | 66 | return u, err 67 | } 68 | 69 | // Convert database.sql types to float64s for Prometheus consumption. Null types are mapped to NaN. string and []byte 70 | // types are mapped as NaN and !ok 71 | func dbToFloat64(t interface{}) (float64, bool) { 72 | switch v := t.(type) { 73 | case int64: 74 | return float64(v), true 75 | case float64: 76 | return v, true 77 | case time.Time: 78 | return float64(v.Unix()), true 79 | case []byte: 80 | // Try and convert to string and then parse to a float64 81 | strV := string(v) 82 | result, err := strconv.ParseFloat(strV, 64) 83 | if err != nil { 84 | logger.Info("Could not parse []byte", "err", err) 85 | return math.NaN(), false 86 | } 87 | return result, true 88 | case string: 89 | result, err := strconv.ParseFloat(v, 64) 90 | if err != nil { 91 | logger.Info("Could not parse string", "err", err) 92 | return math.NaN(), false 93 | } 94 | return result, true 95 | case bool: 96 | if v { 97 | return 1.0, true 98 | } 99 | return 0.0, true 100 | case nil: 101 | return math.NaN(), true 102 | default: 103 | return math.NaN(), false 104 | } 105 | } 106 | 107 | // Convert database.sql types to uint64 for Prometheus consumption. Null types are mapped to 0. string and []byte 108 | // types are mapped as 0 and !ok 109 | func dbToUint64(t interface{}) (uint64, bool) { 110 | switch v := t.(type) { 111 | case uint64: 112 | return v, true 113 | case int64: 114 | return uint64(v), true 115 | case float64: 116 | return uint64(v), true 117 | case time.Time: 118 | return uint64(v.Unix()), true 119 | case []byte: 120 | // Try and convert to string and then parse to a uint64 121 | strV := string(v) 122 | result, err := strconv.ParseUint(strV, 10, 64) 123 | if err != nil { 124 | logger.Info("Could not parse []byte", "err", err) 125 | return 0, false 126 | } 127 | return result, true 128 | case string: 129 | result, err := strconv.ParseUint(v, 10, 64) 130 | if err != nil { 131 | logger.Info("Could not parse string", "err", err) 132 | return 0, false 133 | } 134 | return result, true 135 | case bool: 136 | if v { 137 | return 1, true 138 | } 139 | return 0, true 140 | case nil: 141 | return 0, true 142 | default: 143 | return 0, false 144 | } 145 | } 146 | 147 | // Convert database.sql to string for Prometheus labels. Null types are mapped to empty strings. 148 | func dbToString(t interface{}) (string, bool) { 149 | switch v := t.(type) { 150 | case int64: 151 | return fmt.Sprintf("%v", v), true 152 | case float64: 153 | return fmt.Sprintf("%v", v), true 154 | case time.Time: 155 | return fmt.Sprintf("%v", v.Unix()), true 156 | case nil: 157 | return "", true 158 | case []byte: 159 | // Try and convert to string 160 | return string(v), true 161 | case string: 162 | return strings.ToValidUTF8(v, "�"), true 163 | case bool: 164 | if v { 165 | return "true", true 166 | } 167 | return "false", true 168 | default: 169 | return "", false 170 | } 171 | } 172 | 173 | func parseFingerprint(url string) (string, error) { 174 | dsn, err := pq.ParseURL(url) 175 | if err != nil { 176 | dsn = url 177 | } 178 | 179 | pairs := strings.Split(dsn, " ") 180 | kv := make(map[string]string, len(pairs)) 181 | for _, pair := range pairs { 182 | splitted := strings.SplitN(pair, "=", 2) 183 | if len(splitted) != 2 { 184 | return "", fmt.Errorf("malformed dsn %q", dsn) 185 | } 186 | // Newer versions of pq.ParseURL quote values so trim them off if they exist 187 | key := strings.Trim(splitted[0], "'\"") 188 | value := strings.Trim(splitted[1], "'\"") 189 | kv[key] = value 190 | } 191 | 192 | var fingerprint string 193 | 194 | if host, ok := kv["host"]; ok { 195 | fingerprint += host 196 | } else { 197 | fingerprint += "localhost" 198 | } 199 | 200 | if port, ok := kv["port"]; ok { 201 | fingerprint += ":" + port 202 | } else { 203 | fingerprint += ":5432" 204 | } 205 | 206 | return fingerprint, nil 207 | } 208 | 209 | func loggableDSN(dsn string) string { 210 | pDSN, err := url.Parse(dsn) 211 | if err != nil { 212 | return "could not parse DATA_SOURCE_NAME" 213 | } 214 | // Blank user info if not nil 215 | if pDSN.User != nil { 216 | pDSN.User = url.UserPassword(pDSN.User.Username(), "PASSWORD_REMOVED") 217 | } 218 | 219 | return pDSN.String() 220 | } 221 | -------------------------------------------------------------------------------- /collector/collector_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "strings" 17 | 18 | "github.com/prometheus/client_golang/prometheus" 19 | dto "github.com/prometheus/client_model/go" 20 | ) 21 | 22 | type labelMap map[string]string 23 | 24 | type MetricResult struct { 25 | labels labelMap 26 | value float64 27 | metricType dto.MetricType 28 | } 29 | 30 | func readMetric(m prometheus.Metric) MetricResult { 31 | pb := &dto.Metric{} 32 | m.Write(pb) 33 | labels := make(labelMap, len(pb.Label)) 34 | for _, v := range pb.Label { 35 | labels[v.GetName()] = v.GetValue() 36 | } 37 | if pb.Gauge != nil { 38 | return MetricResult{labels: labels, value: pb.GetGauge().GetValue(), metricType: dto.MetricType_GAUGE} 39 | } 40 | if pb.Counter != nil { 41 | return MetricResult{labels: labels, value: pb.GetCounter().GetValue(), metricType: dto.MetricType_COUNTER} 42 | } 43 | if pb.Untyped != nil { 44 | return MetricResult{labels: labels, value: pb.GetUntyped().GetValue(), metricType: dto.MetricType_UNTYPED} 45 | } 46 | panic("Unsupported metric type") 47 | } 48 | 49 | func sanitizeQuery(q string) string { 50 | q = strings.Join(strings.Fields(q), " ") 51 | q = strings.ReplaceAll(q, "(", "\\(") 52 | q = strings.ReplaceAll(q, "?", "\\?") 53 | q = strings.ReplaceAll(q, ")", "\\)") 54 | q = strings.ReplaceAll(q, "[", "\\[") 55 | q = strings.ReplaceAll(q, "]", "\\]") 56 | q = strings.ReplaceAll(q, "{", "\\{") 57 | q = strings.ReplaceAll(q, "}", "\\}") 58 | q = strings.ReplaceAll(q, "*", "\\*") 59 | q = strings.ReplaceAll(q, "^", "\\^") 60 | q = strings.ReplaceAll(q, "$", "\\$") 61 | return q 62 | } 63 | -------------------------------------------------------------------------------- /collector/instance.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "database/sql" 18 | "fmt" 19 | "regexp" 20 | 21 | "github.com/blang/semver/v4" 22 | ) 23 | 24 | type instance struct { 25 | dsn string 26 | db *sql.DB 27 | version semver.Version 28 | } 29 | 30 | func newInstance(dsn string) (*instance, error) { 31 | i := &instance{ 32 | dsn: dsn, 33 | } 34 | 35 | // "Create" a database handle to verify the DSN provided is valid. 36 | // Open is not guaranteed to create a connection. 37 | db, err := sql.Open("postgres", dsn) 38 | if err != nil { 39 | return nil, err 40 | } 41 | db.Close() 42 | 43 | return i, nil 44 | } 45 | 46 | // copy returns a copy of the instance. 47 | func (i *instance) copy() *instance { 48 | return &instance{ 49 | dsn: i.dsn, 50 | } 51 | } 52 | 53 | func (i *instance) setup() error { 54 | db, err := sql.Open("postgres", i.dsn) 55 | if err != nil { 56 | return err 57 | } 58 | db.SetMaxOpenConns(1) 59 | db.SetMaxIdleConns(1) 60 | i.db = db 61 | 62 | version, err := queryVersion(i.db) 63 | if err != nil { 64 | return fmt.Errorf("error querying postgresql version: %w", err) 65 | } else { 66 | i.version = version 67 | } 68 | return nil 69 | } 70 | 71 | func (i *instance) getDB() *sql.DB { 72 | return i.db 73 | } 74 | 75 | func (i *instance) Close() error { 76 | return i.db.Close() 77 | } 78 | 79 | // Regex used to get the "short-version" from the postgres version field. 80 | // The result of SELECT version() is something like "PostgreSQL 9.6.2 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 6.2.1 20160830, 64-bit" 81 | var versionRegex = regexp.MustCompile(`^\w+ ((\d+)(\.\d+)?(\.\d+)?)`) 82 | var serverVersionRegex = regexp.MustCompile(`^((\d+)(\.\d+)?(\.\d+)?)`) 83 | 84 | func queryVersion(db *sql.DB) (semver.Version, error) { 85 | var version string 86 | err := db.QueryRow("SELECT version();").Scan(&version) 87 | if err != nil { 88 | return semver.Version{}, err 89 | } 90 | submatches := versionRegex.FindStringSubmatch(version) 91 | if len(submatches) > 1 { 92 | return semver.ParseTolerant(submatches[1]) 93 | } 94 | 95 | // We could also try to parse the version from the server_version field. 96 | // This is of the format 13.3 (Debian 13.3-1.pgdg100+1) 97 | err = db.QueryRow("SHOW server_version;").Scan(&version) 98 | if err != nil { 99 | return semver.Version{}, err 100 | } 101 | submatches = serverVersionRegex.FindStringSubmatch(version) 102 | if len(submatches) > 1 { 103 | return semver.ParseTolerant(submatches[1]) 104 | } 105 | return semver.Version{}, fmt.Errorf("could not parse version from %q", version) 106 | } 107 | -------------------------------------------------------------------------------- /collector/pg_database.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | "log/slog" 20 | 21 | "github.com/prometheus/client_golang/prometheus" 22 | ) 23 | 24 | const databaseSubsystem = "database" 25 | 26 | func init() { 27 | registerCollector(databaseSubsystem, defaultEnabled, NewPGDatabaseCollector) 28 | } 29 | 30 | type PGDatabaseCollector struct { 31 | log *slog.Logger 32 | excludedDatabases []string 33 | } 34 | 35 | func NewPGDatabaseCollector(config collectorConfig) (Collector, error) { 36 | exclude := config.excludeDatabases 37 | if exclude == nil { 38 | exclude = []string{} 39 | } 40 | return &PGDatabaseCollector{ 41 | log: config.logger, 42 | excludedDatabases: exclude, 43 | }, nil 44 | } 45 | 46 | var ( 47 | pgDatabaseSizeDesc = prometheus.NewDesc( 48 | prometheus.BuildFQName( 49 | namespace, 50 | databaseSubsystem, 51 | "size_bytes", 52 | ), 53 | "Disk space used by the database", 54 | []string{"datname"}, nil, 55 | ) 56 | pgDatabaseConnectionLimitsDesc = prometheus.NewDesc( 57 | prometheus.BuildFQName( 58 | namespace, 59 | databaseSubsystem, 60 | "connection_limit", 61 | ), 62 | "Connection limit set for the database", 63 | []string{"datname"}, nil, 64 | ) 65 | 66 | pgDatabaseQuery = "SELECT pg_database.datname, pg_database.datconnlimit FROM pg_database;" 67 | pgDatabaseSizeQuery = "SELECT pg_database_size($1)" 68 | ) 69 | 70 | // Update implements Collector and exposes database size and connection limits. 71 | // It is called by the Prometheus registry when collecting metrics. 72 | // The list of databases is retrieved from pg_database and filtered 73 | // by the excludeDatabase config parameter. The tradeoff here is that 74 | // we have to query the list of databases and then query the size of 75 | // each database individually. This is because we can't filter the 76 | // list of databases in the query because the list of excluded 77 | // databases is dynamic. 78 | func (c PGDatabaseCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 79 | db := instance.getDB() 80 | // Query the list of databases 81 | rows, err := db.QueryContext(ctx, 82 | pgDatabaseQuery, 83 | ) 84 | if err != nil { 85 | return err 86 | } 87 | defer rows.Close() 88 | 89 | var databases []string 90 | 91 | for rows.Next() { 92 | var datname sql.NullString 93 | var connLimit sql.NullInt64 94 | if err := rows.Scan(&datname, &connLimit); err != nil { 95 | return err 96 | } 97 | 98 | if !datname.Valid { 99 | continue 100 | } 101 | database := datname.String 102 | // Ignore excluded databases 103 | // Filtering is done here instead of in the query to avoid 104 | // a complicated NOT IN query with a variable number of parameters 105 | if sliceContains(c.excludedDatabases, database) { 106 | continue 107 | } 108 | 109 | databases = append(databases, database) 110 | 111 | connLimitMetric := 0.0 112 | if connLimit.Valid { 113 | connLimitMetric = float64(connLimit.Int64) 114 | } 115 | ch <- prometheus.MustNewConstMetric( 116 | pgDatabaseConnectionLimitsDesc, 117 | prometheus.GaugeValue, connLimitMetric, database, 118 | ) 119 | } 120 | 121 | // Query the size of the databases 122 | for _, datname := range databases { 123 | var size sql.NullFloat64 124 | err = db.QueryRowContext(ctx, pgDatabaseSizeQuery, datname).Scan(&size) 125 | if err != nil { 126 | return err 127 | } 128 | 129 | sizeMetric := 0.0 130 | if size.Valid { 131 | sizeMetric = size.Float64 132 | } 133 | ch <- prometheus.MustNewConstMetric( 134 | pgDatabaseSizeDesc, 135 | prometheus.GaugeValue, sizeMetric, datname, 136 | ) 137 | 138 | } 139 | return rows.Err() 140 | } 141 | 142 | func sliceContains(slice []string, s string) bool { 143 | for _, item := range slice { 144 | if item == s { 145 | return true 146 | } 147 | } 148 | return false 149 | } 150 | -------------------------------------------------------------------------------- /collector/pg_database_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGDatabaseCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | mock.ExpectQuery(sanitizeQuery(pgDatabaseQuery)).WillReturnRows(sqlmock.NewRows([]string{"datname", "datconnlimit"}). 35 | AddRow("postgres", 15)) 36 | 37 | mock.ExpectQuery(sanitizeQuery(pgDatabaseSizeQuery)).WithArgs("postgres").WillReturnRows(sqlmock.NewRows([]string{"pg_database_size"}). 38 | AddRow(1024)) 39 | 40 | ch := make(chan prometheus.Metric) 41 | go func() { 42 | defer close(ch) 43 | c := PGDatabaseCollector{} 44 | if err := c.Update(context.Background(), inst, ch); err != nil { 45 | t.Errorf("Error calling PGDatabaseCollector.Update: %s", err) 46 | } 47 | }() 48 | 49 | expected := []MetricResult{ 50 | {labels: labelMap{"datname": "postgres"}, value: 15, metricType: dto.MetricType_GAUGE}, 51 | {labels: labelMap{"datname": "postgres"}, value: 1024, metricType: dto.MetricType_GAUGE}, 52 | } 53 | convey.Convey("Metrics comparison", t, func() { 54 | for _, expect := range expected { 55 | m := readMetric(<-ch) 56 | convey.So(expect, convey.ShouldResemble, m) 57 | } 58 | }) 59 | if err := mock.ExpectationsWereMet(); err != nil { 60 | t.Errorf("there were unfulfilled exceptions: %s", err) 61 | } 62 | } 63 | 64 | // TODO add a null db test 65 | 66 | func TestPGDatabaseCollectorNullMetric(t *testing.T) { 67 | db, mock, err := sqlmock.New() 68 | if err != nil { 69 | t.Fatalf("Error opening a stub db connection: %s", err) 70 | } 71 | defer db.Close() 72 | 73 | inst := &instance{db: db} 74 | 75 | mock.ExpectQuery(sanitizeQuery(pgDatabaseQuery)).WillReturnRows(sqlmock.NewRows([]string{"datname", "datconnlimit"}). 76 | AddRow("postgres", nil)) 77 | 78 | mock.ExpectQuery(sanitizeQuery(pgDatabaseSizeQuery)).WithArgs("postgres").WillReturnRows(sqlmock.NewRows([]string{"pg_database_size"}). 79 | AddRow(nil)) 80 | 81 | ch := make(chan prometheus.Metric) 82 | go func() { 83 | defer close(ch) 84 | c := PGDatabaseCollector{} 85 | if err := c.Update(context.Background(), inst, ch); err != nil { 86 | t.Errorf("Error calling PGDatabaseCollector.Update: %s", err) 87 | } 88 | }() 89 | 90 | expected := []MetricResult{ 91 | {labels: labelMap{"datname": "postgres"}, value: 0, metricType: dto.MetricType_GAUGE}, 92 | {labels: labelMap{"datname": "postgres"}, value: 0, metricType: dto.MetricType_GAUGE}, 93 | } 94 | convey.Convey("Metrics comparison", t, func() { 95 | for _, expect := range expected { 96 | m := readMetric(<-ch) 97 | convey.So(expect, convey.ShouldResemble, m) 98 | } 99 | }) 100 | if err := mock.ExpectationsWereMet(); err != nil { 101 | t.Errorf("there were unfulfilled exceptions: %s", err) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /collector/pg_database_wraparound.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | "log/slog" 20 | 21 | "github.com/prometheus/client_golang/prometheus" 22 | ) 23 | 24 | const databaseWraparoundSubsystem = "database_wraparound" 25 | 26 | func init() { 27 | registerCollector(databaseWraparoundSubsystem, defaultDisabled, NewPGDatabaseWraparoundCollector) 28 | } 29 | 30 | type PGDatabaseWraparoundCollector struct { 31 | log *slog.Logger 32 | } 33 | 34 | func NewPGDatabaseWraparoundCollector(config collectorConfig) (Collector, error) { 35 | return &PGDatabaseWraparoundCollector{log: config.logger}, nil 36 | } 37 | 38 | var ( 39 | databaseWraparoundAgeDatfrozenxid = prometheus.NewDesc( 40 | prometheus.BuildFQName(namespace, databaseWraparoundSubsystem, "age_datfrozenxid_seconds"), 41 | "Age of the oldest transaction ID that has not been frozen.", 42 | []string{"datname"}, 43 | prometheus.Labels{}, 44 | ) 45 | databaseWraparoundAgeDatminmxid = prometheus.NewDesc( 46 | prometheus.BuildFQName(namespace, databaseWraparoundSubsystem, "age_datminmxid_seconds"), 47 | "Age of the oldest multi-transaction ID that has been replaced with a transaction ID.", 48 | []string{"datname"}, 49 | prometheus.Labels{}, 50 | ) 51 | 52 | databaseWraparoundQuery = ` 53 | SELECT 54 | datname, 55 | age(d.datfrozenxid) as age_datfrozenxid, 56 | mxid_age(d.datminmxid) as age_datminmxid 57 | FROM 58 | pg_catalog.pg_database d 59 | WHERE 60 | d.datallowconn 61 | ` 62 | ) 63 | 64 | func (c *PGDatabaseWraparoundCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 65 | db := instance.getDB() 66 | rows, err := db.QueryContext(ctx, 67 | databaseWraparoundQuery) 68 | 69 | if err != nil { 70 | return err 71 | } 72 | defer rows.Close() 73 | 74 | for rows.Next() { 75 | var datname sql.NullString 76 | var ageDatfrozenxid, ageDatminmxid sql.NullFloat64 77 | 78 | if err := rows.Scan(&datname, &ageDatfrozenxid, &ageDatminmxid); err != nil { 79 | return err 80 | } 81 | 82 | if !datname.Valid { 83 | c.log.Debug("Skipping database with NULL name") 84 | continue 85 | } 86 | if !ageDatfrozenxid.Valid { 87 | c.log.Debug("Skipping stat emission with NULL age_datfrozenxid") 88 | continue 89 | } 90 | if !ageDatminmxid.Valid { 91 | c.log.Debug("Skipping stat emission with NULL age_datminmxid") 92 | continue 93 | } 94 | 95 | ageDatfrozenxidMetric := ageDatfrozenxid.Float64 96 | 97 | ch <- prometheus.MustNewConstMetric( 98 | databaseWraparoundAgeDatfrozenxid, 99 | prometheus.GaugeValue, 100 | ageDatfrozenxidMetric, datname.String, 101 | ) 102 | 103 | ageDatminmxidMetric := ageDatminmxid.Float64 104 | ch <- prometheus.MustNewConstMetric( 105 | databaseWraparoundAgeDatminmxid, 106 | prometheus.GaugeValue, 107 | ageDatminmxidMetric, datname.String, 108 | ) 109 | } 110 | if err := rows.Err(); err != nil { 111 | return err 112 | } 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /collector/pg_database_wraparound_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGDatabaseWraparoundCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | inst := &instance{db: db} 32 | columns := []string{ 33 | "datname", 34 | "age_datfrozenxid", 35 | "age_datminmxid", 36 | } 37 | rows := sqlmock.NewRows(columns). 38 | AddRow("newreddit", 87126426, 0) 39 | 40 | mock.ExpectQuery(sanitizeQuery(databaseWraparoundQuery)).WillReturnRows(rows) 41 | 42 | ch := make(chan prometheus.Metric) 43 | go func() { 44 | defer close(ch) 45 | c := PGDatabaseWraparoundCollector{} 46 | 47 | if err := c.Update(context.Background(), inst, ch); err != nil { 48 | t.Errorf("Error calling PGDatabaseWraparoundCollector.Update: %s", err) 49 | } 50 | }() 51 | expected := []MetricResult{ 52 | {labels: labelMap{"datname": "newreddit"}, value: 87126426, metricType: dto.MetricType_GAUGE}, 53 | {labels: labelMap{"datname": "newreddit"}, value: 0, metricType: dto.MetricType_GAUGE}, 54 | } 55 | convey.Convey("Metrics comparison", t, func() { 56 | for _, expect := range expected { 57 | m := readMetric(<-ch) 58 | convey.So(expect, convey.ShouldResemble, m) 59 | } 60 | }) 61 | if err := mock.ExpectationsWereMet(); err != nil { 62 | t.Errorf("there were unfulfilled exceptions: %s", err) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /collector/pg_locks.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | "log/slog" 20 | 21 | "github.com/prometheus/client_golang/prometheus" 22 | ) 23 | 24 | const locksSubsystem = "locks" 25 | 26 | func init() { 27 | registerCollector(locksSubsystem, defaultEnabled, NewPGLocksCollector) 28 | } 29 | 30 | type PGLocksCollector struct { 31 | log *slog.Logger 32 | } 33 | 34 | func NewPGLocksCollector(config collectorConfig) (Collector, error) { 35 | return &PGLocksCollector{ 36 | log: config.logger, 37 | }, nil 38 | } 39 | 40 | var ( 41 | pgLocksDesc = prometheus.NewDesc( 42 | prometheus.BuildFQName( 43 | namespace, 44 | locksSubsystem, 45 | "count", 46 | ), 47 | "Number of locks", 48 | []string{"datname", "mode"}, nil, 49 | ) 50 | 51 | pgLocksQuery = ` 52 | SELECT 53 | pg_database.datname as datname, 54 | tmp.mode as mode, 55 | COALESCE(count, 0) as count 56 | FROM 57 | ( 58 | VALUES 59 | ('accesssharelock'), 60 | ('rowsharelock'), 61 | ('rowexclusivelock'), 62 | ('shareupdateexclusivelock'), 63 | ('sharelock'), 64 | ('sharerowexclusivelock'), 65 | ('exclusivelock'), 66 | ('accessexclusivelock'), 67 | ('sireadlock') 68 | ) AS tmp(mode) 69 | CROSS JOIN pg_database 70 | LEFT JOIN ( 71 | SELECT 72 | database, 73 | lower(mode) AS mode, 74 | count(*) AS count 75 | FROM 76 | pg_locks 77 | WHERE 78 | database IS NOT NULL 79 | GROUP BY 80 | database, 81 | lower(mode) 82 | ) AS tmp2 ON tmp.mode = tmp2.mode 83 | and pg_database.oid = tmp2.database 84 | ORDER BY 85 | 1 86 | ` 87 | ) 88 | 89 | // Update implements Collector and exposes database locks. 90 | // It is called by the Prometheus registry when collecting metrics. 91 | func (c PGLocksCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 92 | db := instance.getDB() 93 | // Query the list of databases 94 | rows, err := db.QueryContext(ctx, 95 | pgLocksQuery, 96 | ) 97 | if err != nil { 98 | return err 99 | } 100 | defer rows.Close() 101 | 102 | var datname, mode sql.NullString 103 | var count sql.NullInt64 104 | 105 | for rows.Next() { 106 | if err := rows.Scan(&datname, &mode, &count); err != nil { 107 | return err 108 | } 109 | 110 | if !datname.Valid || !mode.Valid { 111 | continue 112 | } 113 | 114 | countMetric := 0.0 115 | if count.Valid { 116 | countMetric = float64(count.Int64) 117 | } 118 | 119 | ch <- prometheus.MustNewConstMetric( 120 | pgLocksDesc, 121 | prometheus.GaugeValue, countMetric, 122 | datname.String, mode.String, 123 | ) 124 | } 125 | if err := rows.Err(); err != nil { 126 | return err 127 | } 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /collector/pg_locks_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGLocksCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | rows := sqlmock.NewRows([]string{"datname", "mode", "count"}). 35 | AddRow("test", "exclusivelock", 42) 36 | 37 | mock.ExpectQuery(sanitizeQuery(pgLocksQuery)).WillReturnRows(rows) 38 | 39 | ch := make(chan prometheus.Metric) 40 | go func() { 41 | defer close(ch) 42 | c := PGLocksCollector{} 43 | if err := c.Update(context.Background(), inst, ch); err != nil { 44 | t.Errorf("Error calling PGLocksCollector.Update: %s", err) 45 | } 46 | }() 47 | 48 | expected := []MetricResult{ 49 | {labels: labelMap{"datname": "test", "mode": "exclusivelock"}, value: 42, metricType: dto.MetricType_GAUGE}, 50 | } 51 | convey.Convey("Metrics comparison", t, func() { 52 | for _, expect := range expected { 53 | m := readMetric(<-ch) 54 | convey.So(expect, convey.ShouldResemble, m) 55 | } 56 | }) 57 | if err := mock.ExpectationsWereMet(); err != nil { 58 | t.Errorf("there were unfulfilled exceptions: %s", err) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /collector/pg_long_running_transactions.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "log/slog" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | ) 22 | 23 | const longRunningTransactionsSubsystem = "long_running_transactions" 24 | 25 | func init() { 26 | registerCollector(longRunningTransactionsSubsystem, defaultDisabled, NewPGLongRunningTransactionsCollector) 27 | } 28 | 29 | type PGLongRunningTransactionsCollector struct { 30 | log *slog.Logger 31 | } 32 | 33 | func NewPGLongRunningTransactionsCollector(config collectorConfig) (Collector, error) { 34 | return &PGLongRunningTransactionsCollector{log: config.logger}, nil 35 | } 36 | 37 | var ( 38 | longRunningTransactionsCount = prometheus.NewDesc( 39 | "pg_long_running_transactions", 40 | "Current number of long running transactions", 41 | []string{}, 42 | prometheus.Labels{}, 43 | ) 44 | 45 | longRunningTransactionsAgeInSeconds = prometheus.NewDesc( 46 | prometheus.BuildFQName(namespace, longRunningTransactionsSubsystem, "oldest_timestamp_seconds"), 47 | "The current maximum transaction age in seconds", 48 | []string{}, 49 | prometheus.Labels{}, 50 | ) 51 | 52 | longRunningTransactionsQuery = ` 53 | SELECT 54 | COUNT(*) as transactions, 55 | MAX(EXTRACT(EPOCH FROM clock_timestamp() - pg_stat_activity.xact_start)) AS oldest_timestamp_seconds 56 | FROM pg_catalog.pg_stat_activity 57 | WHERE state IS DISTINCT FROM 'idle' 58 | AND query NOT LIKE 'autovacuum:%' 59 | AND pg_stat_activity.xact_start IS NOT NULL; 60 | ` 61 | ) 62 | 63 | func (PGLongRunningTransactionsCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 64 | db := instance.getDB() 65 | rows, err := db.QueryContext(ctx, 66 | longRunningTransactionsQuery) 67 | 68 | if err != nil { 69 | return err 70 | } 71 | defer rows.Close() 72 | 73 | for rows.Next() { 74 | var transactions, ageInSeconds float64 75 | 76 | if err := rows.Scan(&transactions, &ageInSeconds); err != nil { 77 | return err 78 | } 79 | 80 | ch <- prometheus.MustNewConstMetric( 81 | longRunningTransactionsCount, 82 | prometheus.GaugeValue, 83 | transactions, 84 | ) 85 | ch <- prometheus.MustNewConstMetric( 86 | longRunningTransactionsAgeInSeconds, 87 | prometheus.GaugeValue, 88 | ageInSeconds, 89 | ) 90 | } 91 | if err := rows.Err(); err != nil { 92 | return err 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /collector/pg_long_running_transactions_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGLongRunningTransactionsCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | inst := &instance{db: db} 32 | columns := []string{ 33 | "transactions", 34 | "age_in_seconds", 35 | } 36 | rows := sqlmock.NewRows(columns). 37 | AddRow(20, 1200) 38 | 39 | mock.ExpectQuery(sanitizeQuery(longRunningTransactionsQuery)).WillReturnRows(rows) 40 | 41 | ch := make(chan prometheus.Metric) 42 | go func() { 43 | defer close(ch) 44 | c := PGLongRunningTransactionsCollector{} 45 | 46 | if err := c.Update(context.Background(), inst, ch); err != nil { 47 | t.Errorf("Error calling PGLongRunningTransactionsCollector.Update: %s", err) 48 | } 49 | }() 50 | expected := []MetricResult{ 51 | {labels: labelMap{}, value: 20, metricType: dto.MetricType_GAUGE}, 52 | {labels: labelMap{}, value: 1200, metricType: dto.MetricType_GAUGE}, 53 | } 54 | convey.Convey("Metrics comparison", t, func() { 55 | for _, expect := range expected { 56 | m := readMetric(<-ch) 57 | convey.So(expect, convey.ShouldResemble, m) 58 | } 59 | }) 60 | if err := mock.ExpectationsWereMet(); err != nil { 61 | t.Errorf("there were unfulfilled exceptions: %s", err) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /collector/pg_postmaster.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | ) 22 | 23 | const postmasterSubsystem = "postmaster" 24 | 25 | func init() { 26 | registerCollector(postmasterSubsystem, defaultDisabled, NewPGPostmasterCollector) 27 | } 28 | 29 | type PGPostmasterCollector struct { 30 | } 31 | 32 | func NewPGPostmasterCollector(collectorConfig) (Collector, error) { 33 | return &PGPostmasterCollector{}, nil 34 | } 35 | 36 | var ( 37 | pgPostMasterStartTimeSeconds = prometheus.NewDesc( 38 | prometheus.BuildFQName( 39 | namespace, 40 | postmasterSubsystem, 41 | "start_time_seconds", 42 | ), 43 | "Time at which postmaster started", 44 | []string{}, nil, 45 | ) 46 | 47 | pgPostmasterQuery = "SELECT extract(epoch from pg_postmaster_start_time) from pg_postmaster_start_time();" 48 | ) 49 | 50 | func (c *PGPostmasterCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 51 | db := instance.getDB() 52 | row := db.QueryRowContext(ctx, 53 | pgPostmasterQuery) 54 | 55 | var startTimeSeconds sql.NullFloat64 56 | err := row.Scan(&startTimeSeconds) 57 | if err != nil { 58 | return err 59 | } 60 | startTimeSecondsMetric := 0.0 61 | if startTimeSeconds.Valid { 62 | startTimeSecondsMetric = startTimeSeconds.Float64 63 | } 64 | ch <- prometheus.MustNewConstMetric( 65 | pgPostMasterStartTimeSeconds, 66 | prometheus.GaugeValue, startTimeSecondsMetric, 67 | ) 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /collector/pg_postmaster_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPgPostmasterCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | mock.ExpectQuery(sanitizeQuery(pgPostmasterQuery)).WillReturnRows(sqlmock.NewRows([]string{"pg_postmaster_start_time"}). 35 | AddRow(1685739904)) 36 | 37 | ch := make(chan prometheus.Metric) 38 | go func() { 39 | defer close(ch) 40 | c := PGPostmasterCollector{} 41 | 42 | if err := c.Update(context.Background(), inst, ch); err != nil { 43 | t.Errorf("Error calling PGPostmasterCollector.Update: %s", err) 44 | } 45 | }() 46 | 47 | expected := []MetricResult{ 48 | {labels: labelMap{}, value: 1685739904, metricType: dto.MetricType_GAUGE}, 49 | } 50 | convey.Convey("Metrics comparison", t, func() { 51 | for _, expect := range expected { 52 | m := readMetric(<-ch) 53 | convey.So(expect, convey.ShouldResemble, m) 54 | } 55 | }) 56 | if err := mock.ExpectationsWereMet(); err != nil { 57 | t.Errorf("there were unfulfilled exceptions: %s", err) 58 | } 59 | } 60 | 61 | func TestPgPostmasterCollectorNullTime(t *testing.T) { 62 | db, mock, err := sqlmock.New() 63 | if err != nil { 64 | t.Fatalf("Error opening a stub db connection: %s", err) 65 | } 66 | defer db.Close() 67 | 68 | inst := &instance{db: db} 69 | 70 | mock.ExpectQuery(sanitizeQuery(pgPostmasterQuery)).WillReturnRows(sqlmock.NewRows([]string{"pg_postmaster_start_time"}). 71 | AddRow(nil)) 72 | 73 | ch := make(chan prometheus.Metric) 74 | go func() { 75 | defer close(ch) 76 | c := PGPostmasterCollector{} 77 | 78 | if err := c.Update(context.Background(), inst, ch); err != nil { 79 | t.Errorf("Error calling PGPostmasterCollector.Update: %s", err) 80 | } 81 | }() 82 | 83 | expected := []MetricResult{ 84 | {labels: labelMap{}, value: 0, metricType: dto.MetricType_GAUGE}, 85 | } 86 | convey.Convey("Metrics comparison", t, func() { 87 | for _, expect := range expected { 88 | m := readMetric(<-ch) 89 | convey.So(expect, convey.ShouldResemble, m) 90 | } 91 | }) 92 | if err := mock.ExpectationsWereMet(); err != nil { 93 | t.Errorf("there were unfulfilled exceptions: %s", err) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /collector/pg_process_idle.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | "log/slog" 20 | 21 | "github.com/lib/pq" 22 | "github.com/prometheus/client_golang/prometheus" 23 | ) 24 | 25 | func init() { 26 | // Making this default disabled because we have no tests for it 27 | registerCollector(processIdleSubsystem, defaultDisabled, NewPGProcessIdleCollector) 28 | } 29 | 30 | type PGProcessIdleCollector struct { 31 | log *slog.Logger 32 | } 33 | 34 | const processIdleSubsystem = "process_idle" 35 | 36 | func NewPGProcessIdleCollector(config collectorConfig) (Collector, error) { 37 | return &PGProcessIdleCollector{log: config.logger}, nil 38 | } 39 | 40 | var pgProcessIdleSeconds = prometheus.NewDesc( 41 | prometheus.BuildFQName(namespace, processIdleSubsystem, "seconds"), 42 | "Idle time of server processes", 43 | []string{"state", "application_name"}, 44 | prometheus.Labels{}, 45 | ) 46 | 47 | func (PGProcessIdleCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 48 | db := instance.getDB() 49 | row := db.QueryRowContext(ctx, 50 | `WITH 51 | metrics AS ( 52 | SELECT 53 | state, 54 | application_name, 55 | SUM(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - state_change))::bigint)::float AS process_idle_seconds_sum, 56 | COUNT(*) AS process_idle_seconds_count 57 | FROM pg_stat_activity 58 | WHERE state ~ '^idle' 59 | GROUP BY state, application_name 60 | ), 61 | buckets AS ( 62 | SELECT 63 | state, 64 | application_name, 65 | le, 66 | SUM( 67 | CASE WHEN EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - state_change)) <= le 68 | THEN 1 69 | ELSE 0 70 | END 71 | )::bigint AS bucket 72 | FROM 73 | pg_stat_activity, 74 | UNNEST(ARRAY[1, 2, 5, 15, 30, 60, 90, 120, 300]) AS le 75 | GROUP BY state, application_name, le 76 | ORDER BY state, application_name, le 77 | ) 78 | SELECT 79 | state, 80 | application_name, 81 | process_idle_seconds_sum as seconds_sum, 82 | process_idle_seconds_count as seconds_count, 83 | ARRAY_AGG(le) AS seconds, 84 | ARRAY_AGG(bucket) AS seconds_bucket 85 | FROM metrics JOIN buckets USING (state, application_name) 86 | GROUP BY 1, 2, 3, 4;`) 87 | 88 | var state sql.NullString 89 | var applicationName sql.NullString 90 | var secondsSum sql.NullFloat64 91 | var secondsCount sql.NullInt64 92 | var seconds []float64 93 | var secondsBucket []int64 94 | 95 | err := row.Scan(&state, &applicationName, &secondsSum, &secondsCount, pq.Array(&seconds), pq.Array(&secondsBucket)) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | var buckets = make(map[float64]uint64, len(seconds)) 101 | for i, second := range seconds { 102 | if i >= len(secondsBucket) { 103 | break 104 | } 105 | buckets[second] = uint64(secondsBucket[i]) 106 | } 107 | 108 | stateLabel := "unknown" 109 | if state.Valid { 110 | stateLabel = state.String 111 | } 112 | 113 | applicationNameLabel := "unknown" 114 | if applicationName.Valid { 115 | applicationNameLabel = applicationName.String 116 | } 117 | 118 | var secondsCountMetric uint64 119 | if secondsCount.Valid { 120 | secondsCountMetric = uint64(secondsCount.Int64) 121 | } 122 | secondsSumMetric := 0.0 123 | if secondsSum.Valid { 124 | secondsSumMetric = secondsSum.Float64 125 | } 126 | ch <- prometheus.MustNewConstHistogram( 127 | pgProcessIdleSeconds, 128 | secondsCountMetric, secondsSumMetric, buckets, 129 | stateLabel, applicationNameLabel, 130 | ) 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /collector/pg_replication.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | 19 | "github.com/prometheus/client_golang/prometheus" 20 | ) 21 | 22 | const replicationSubsystem = "replication" 23 | 24 | func init() { 25 | registerCollector(replicationSubsystem, defaultEnabled, NewPGReplicationCollector) 26 | } 27 | 28 | type PGReplicationCollector struct { 29 | } 30 | 31 | func NewPGReplicationCollector(collectorConfig) (Collector, error) { 32 | return &PGReplicationCollector{}, nil 33 | } 34 | 35 | var ( 36 | pgReplicationLag = prometheus.NewDesc( 37 | prometheus.BuildFQName( 38 | namespace, 39 | replicationSubsystem, 40 | "lag_seconds", 41 | ), 42 | "Replication lag behind master in seconds", 43 | []string{}, nil, 44 | ) 45 | pgReplicationIsReplica = prometheus.NewDesc( 46 | prometheus.BuildFQName( 47 | namespace, 48 | replicationSubsystem, 49 | "is_replica", 50 | ), 51 | "Indicates if the server is a replica", 52 | []string{}, nil, 53 | ) 54 | pgReplicationLastReplay = prometheus.NewDesc( 55 | prometheus.BuildFQName( 56 | namespace, 57 | replicationSubsystem, 58 | "last_replay_seconds", 59 | ), 60 | "Age of last replay in seconds", 61 | []string{}, nil, 62 | ) 63 | 64 | pgReplicationQuery = `SELECT 65 | CASE 66 | WHEN NOT pg_is_in_recovery() THEN 0 67 | WHEN pg_last_wal_receive_lsn () = pg_last_wal_replay_lsn () THEN 0 68 | ELSE GREATEST (0, EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))) 69 | END AS lag, 70 | CASE 71 | WHEN pg_is_in_recovery() THEN 1 72 | ELSE 0 73 | END as is_replica, 74 | GREATEST (0, EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))) as last_replay` 75 | ) 76 | 77 | func (c *PGReplicationCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 78 | db := instance.getDB() 79 | row := db.QueryRowContext(ctx, 80 | pgReplicationQuery, 81 | ) 82 | 83 | var lag float64 84 | var isReplica int64 85 | var replayAge float64 86 | err := row.Scan(&lag, &isReplica, &replayAge) 87 | if err != nil { 88 | return err 89 | } 90 | ch <- prometheus.MustNewConstMetric( 91 | pgReplicationLag, 92 | prometheus.GaugeValue, lag, 93 | ) 94 | ch <- prometheus.MustNewConstMetric( 95 | pgReplicationIsReplica, 96 | prometheus.GaugeValue, float64(isReplica), 97 | ) 98 | ch <- prometheus.MustNewConstMetric( 99 | pgReplicationLastReplay, 100 | prometheus.GaugeValue, replayAge, 101 | ) 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /collector/pg_replication_slot.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | "log/slog" 20 | 21 | "github.com/blang/semver/v4" 22 | "github.com/prometheus/client_golang/prometheus" 23 | ) 24 | 25 | const replicationSlotSubsystem = "replication_slot" 26 | 27 | func init() { 28 | registerCollector(replicationSlotSubsystem, defaultEnabled, NewPGReplicationSlotCollector) 29 | } 30 | 31 | type PGReplicationSlotCollector struct { 32 | log *slog.Logger 33 | } 34 | 35 | func NewPGReplicationSlotCollector(config collectorConfig) (Collector, error) { 36 | return &PGReplicationSlotCollector{log: config.logger}, nil 37 | } 38 | 39 | var ( 40 | pgReplicationSlotCurrentWalDesc = prometheus.NewDesc( 41 | prometheus.BuildFQName( 42 | namespace, 43 | replicationSlotSubsystem, 44 | "slot_current_wal_lsn", 45 | ), 46 | "current wal lsn value", 47 | []string{"slot_name", "slot_type"}, nil, 48 | ) 49 | pgReplicationSlotCurrentFlushDesc = prometheus.NewDesc( 50 | prometheus.BuildFQName( 51 | namespace, 52 | replicationSlotSubsystem, 53 | "slot_confirmed_flush_lsn", 54 | ), 55 | "last lsn confirmed flushed to the replication slot", 56 | []string{"slot_name", "slot_type"}, nil, 57 | ) 58 | pgReplicationSlotIsActiveDesc = prometheus.NewDesc( 59 | prometheus.BuildFQName( 60 | namespace, 61 | replicationSlotSubsystem, 62 | "slot_is_active", 63 | ), 64 | "whether the replication slot is active or not", 65 | []string{"slot_name", "slot_type"}, nil, 66 | ) 67 | pgReplicationSlotSafeWal = prometheus.NewDesc( 68 | prometheus.BuildFQName( 69 | namespace, 70 | replicationSlotSubsystem, 71 | "safe_wal_size_bytes", 72 | ), 73 | "number of bytes that can be written to WAL such that this slot is not in danger of getting in state lost", 74 | []string{"slot_name", "slot_type"}, nil, 75 | ) 76 | pgReplicationSlotWalStatus = prometheus.NewDesc( 77 | prometheus.BuildFQName( 78 | namespace, 79 | replicationSlotSubsystem, 80 | "wal_status", 81 | ), 82 | "availability of WAL files claimed by this slot", 83 | []string{"slot_name", "slot_type", "wal_status"}, nil, 84 | ) 85 | pgReplicationSlotQuery = `SELECT 86 | slot_name, 87 | slot_type, 88 | CASE WHEN pg_is_in_recovery() THEN 89 | pg_last_wal_receive_lsn() - '0/0' 90 | ELSE 91 | pg_current_wal_lsn() - '0/0' 92 | END AS current_wal_lsn, 93 | COALESCE(confirmed_flush_lsn, '0/0') - '0/0' AS confirmed_flush_lsn, 94 | active 95 | FROM pg_replication_slots;` 96 | pgReplicationSlotNewQuery = `SELECT 97 | slot_name, 98 | slot_type, 99 | CASE WHEN pg_is_in_recovery() THEN 100 | pg_last_wal_receive_lsn() - '0/0' 101 | ELSE 102 | pg_current_wal_lsn() - '0/0' 103 | END AS current_wal_lsn, 104 | COALESCE(confirmed_flush_lsn, '0/0') - '0/0' AS confirmed_flush_lsn, 105 | active, 106 | safe_wal_size, 107 | wal_status 108 | FROM pg_replication_slots;` 109 | ) 110 | 111 | func (PGReplicationSlotCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 112 | query := pgReplicationSlotQuery 113 | abovePG13 := instance.version.GTE(semver.MustParse("13.0.0")) 114 | if abovePG13 { 115 | query = pgReplicationSlotNewQuery 116 | } 117 | 118 | db := instance.getDB() 119 | rows, err := db.QueryContext(ctx, 120 | query) 121 | if err != nil { 122 | return err 123 | } 124 | defer rows.Close() 125 | 126 | for rows.Next() { 127 | var slotName sql.NullString 128 | var slotType sql.NullString 129 | var walLSN sql.NullFloat64 130 | var flushLSN sql.NullFloat64 131 | var isActive sql.NullBool 132 | var safeWalSize sql.NullInt64 133 | var walStatus sql.NullString 134 | 135 | r := []any{ 136 | &slotName, 137 | &slotType, 138 | &walLSN, 139 | &flushLSN, 140 | &isActive, 141 | } 142 | 143 | if abovePG13 { 144 | r = append(r, &safeWalSize) 145 | r = append(r, &walStatus) 146 | } 147 | 148 | err := rows.Scan(r...) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | isActiveValue := 0.0 154 | if isActive.Valid && isActive.Bool { 155 | isActiveValue = 1.0 156 | } 157 | slotNameLabel := "unknown" 158 | if slotName.Valid { 159 | slotNameLabel = slotName.String 160 | } 161 | slotTypeLabel := "unknown" 162 | if slotType.Valid { 163 | slotTypeLabel = slotType.String 164 | } 165 | 166 | var walLSNMetric float64 167 | if walLSN.Valid { 168 | walLSNMetric = walLSN.Float64 169 | } 170 | ch <- prometheus.MustNewConstMetric( 171 | pgReplicationSlotCurrentWalDesc, 172 | prometheus.GaugeValue, walLSNMetric, slotNameLabel, slotTypeLabel, 173 | ) 174 | if isActive.Valid && isActive.Bool { 175 | var flushLSNMetric float64 176 | if flushLSN.Valid { 177 | flushLSNMetric = flushLSN.Float64 178 | } 179 | ch <- prometheus.MustNewConstMetric( 180 | pgReplicationSlotCurrentFlushDesc, 181 | prometheus.GaugeValue, flushLSNMetric, slotNameLabel, slotTypeLabel, 182 | ) 183 | } 184 | ch <- prometheus.MustNewConstMetric( 185 | pgReplicationSlotIsActiveDesc, 186 | prometheus.GaugeValue, isActiveValue, slotNameLabel, slotTypeLabel, 187 | ) 188 | 189 | if safeWalSize.Valid { 190 | ch <- prometheus.MustNewConstMetric( 191 | pgReplicationSlotSafeWal, 192 | prometheus.GaugeValue, float64(safeWalSize.Int64), slotNameLabel, slotTypeLabel, 193 | ) 194 | } 195 | 196 | if walStatus.Valid { 197 | ch <- prometheus.MustNewConstMetric( 198 | pgReplicationSlotWalStatus, 199 | prometheus.GaugeValue, 1, slotNameLabel, slotTypeLabel, walStatus.String, 200 | ) 201 | } 202 | } 203 | return rows.Err() 204 | } 205 | -------------------------------------------------------------------------------- /collector/pg_replication_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPgReplicationCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | columns := []string{"lag", "is_replica", "last_replay"} 35 | rows := sqlmock.NewRows(columns). 36 | AddRow(1000, 1, 3) 37 | mock.ExpectQuery(sanitizeQuery(pgReplicationQuery)).WillReturnRows(rows) 38 | 39 | ch := make(chan prometheus.Metric) 40 | go func() { 41 | defer close(ch) 42 | c := PGReplicationCollector{} 43 | 44 | if err := c.Update(context.Background(), inst, ch); err != nil { 45 | t.Errorf("Error calling PGReplicationCollector.Update: %s", err) 46 | } 47 | }() 48 | 49 | expected := []MetricResult{ 50 | {labels: labelMap{}, value: 1000, metricType: dto.MetricType_GAUGE}, 51 | {labels: labelMap{}, value: 1, metricType: dto.MetricType_GAUGE}, 52 | {labels: labelMap{}, value: 3, metricType: dto.MetricType_GAUGE}, 53 | } 54 | 55 | convey.Convey("Metrics comparison", t, func() { 56 | for _, expect := range expected { 57 | m := readMetric(<-ch) 58 | convey.So(expect, convey.ShouldResemble, m) 59 | } 60 | }) 61 | if err := mock.ExpectationsWereMet(); err != nil { 62 | t.Errorf("there were unfulfilled exceptions: %s", err) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /collector/pg_roles.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | "log/slog" 20 | 21 | "github.com/prometheus/client_golang/prometheus" 22 | ) 23 | 24 | const rolesSubsystem = "roles" 25 | 26 | func init() { 27 | registerCollector(rolesSubsystem, defaultEnabled, NewPGRolesCollector) 28 | } 29 | 30 | type PGRolesCollector struct { 31 | log *slog.Logger 32 | } 33 | 34 | func NewPGRolesCollector(config collectorConfig) (Collector, error) { 35 | return &PGRolesCollector{ 36 | log: config.logger, 37 | }, nil 38 | } 39 | 40 | var ( 41 | pgRolesConnectionLimitsDesc = prometheus.NewDesc( 42 | prometheus.BuildFQName( 43 | namespace, 44 | rolesSubsystem, 45 | "connection_limit", 46 | ), 47 | "Connection limit set for the role", 48 | []string{"rolname"}, nil, 49 | ) 50 | 51 | pgRolesConnectionLimitsQuery = "SELECT pg_roles.rolname, pg_roles.rolconnlimit FROM pg_roles" 52 | ) 53 | 54 | // Update implements Collector and exposes roles connection limits. 55 | // It is called by the Prometheus registry when collecting metrics. 56 | func (c PGRolesCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 57 | db := instance.getDB() 58 | // Query the list of databases 59 | rows, err := db.QueryContext(ctx, 60 | pgRolesConnectionLimitsQuery, 61 | ) 62 | if err != nil { 63 | return err 64 | } 65 | defer rows.Close() 66 | 67 | for rows.Next() { 68 | var rolname sql.NullString 69 | var connLimit sql.NullInt64 70 | if err := rows.Scan(&rolname, &connLimit); err != nil { 71 | return err 72 | } 73 | 74 | if !rolname.Valid { 75 | continue 76 | } 77 | rolnameLabel := rolname.String 78 | 79 | if !connLimit.Valid { 80 | continue 81 | } 82 | connLimitMetric := float64(connLimit.Int64) 83 | 84 | ch <- prometheus.MustNewConstMetric( 85 | pgRolesConnectionLimitsDesc, 86 | prometheus.GaugeValue, connLimitMetric, rolnameLabel, 87 | ) 88 | } 89 | 90 | return rows.Err() 91 | } 92 | -------------------------------------------------------------------------------- /collector/pg_roles_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGRolesCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | mock.ExpectQuery(sanitizeQuery(pgRolesConnectionLimitsQuery)).WillReturnRows(sqlmock.NewRows([]string{"rolname", "rolconnlimit"}). 35 | AddRow("postgres", 15)) 36 | 37 | ch := make(chan prometheus.Metric) 38 | go func() { 39 | defer close(ch) 40 | c := PGRolesCollector{} 41 | if err := c.Update(context.Background(), inst, ch); err != nil { 42 | t.Errorf("Error calling PGRolesCollector.Update: %s", err) 43 | } 44 | }() 45 | 46 | expected := []MetricResult{ 47 | {labels: labelMap{"rolname": "postgres"}, value: 15, metricType: dto.MetricType_GAUGE}, 48 | } 49 | convey.Convey("Metrics comparison", t, func() { 50 | for _, expect := range expected { 51 | m := readMetric(<-ch) 52 | convey.So(expect, convey.ShouldResemble, m) 53 | } 54 | }) 55 | if err := mock.ExpectationsWereMet(); err != nil { 56 | t.Errorf("there were unfulfilled exceptions: %s", err) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /collector/pg_stat_activity_autovacuum.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "log/slog" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | ) 22 | 23 | const statActivityAutovacuumSubsystem = "stat_activity_autovacuum" 24 | 25 | func init() { 26 | registerCollector(statActivityAutovacuumSubsystem, defaultDisabled, NewPGStatActivityAutovacuumCollector) 27 | } 28 | 29 | type PGStatActivityAutovacuumCollector struct { 30 | log *slog.Logger 31 | } 32 | 33 | func NewPGStatActivityAutovacuumCollector(config collectorConfig) (Collector, error) { 34 | return &PGStatActivityAutovacuumCollector{log: config.logger}, nil 35 | } 36 | 37 | var ( 38 | statActivityAutovacuumAgeInSeconds = prometheus.NewDesc( 39 | prometheus.BuildFQName(namespace, statActivityAutovacuumSubsystem, "timestamp_seconds"), 40 | "Start timestamp of the vacuum process in seconds", 41 | []string{"relname"}, 42 | prometheus.Labels{}, 43 | ) 44 | 45 | statActivityAutovacuumQuery = ` 46 | SELECT 47 | SPLIT_PART(query, '.', 2) AS relname, 48 | EXTRACT(EPOCH FROM xact_start) AS timestamp_seconds 49 | FROM 50 | pg_catalog.pg_stat_activity 51 | WHERE 52 | query LIKE 'autovacuum:%' 53 | ` 54 | ) 55 | 56 | func (PGStatActivityAutovacuumCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 57 | db := instance.getDB() 58 | rows, err := db.QueryContext(ctx, 59 | statActivityAutovacuumQuery) 60 | 61 | if err != nil { 62 | return err 63 | } 64 | defer rows.Close() 65 | 66 | for rows.Next() { 67 | var relname string 68 | var ageInSeconds float64 69 | 70 | if err := rows.Scan(&relname, &ageInSeconds); err != nil { 71 | return err 72 | } 73 | 74 | ch <- prometheus.MustNewConstMetric( 75 | statActivityAutovacuumAgeInSeconds, 76 | prometheus.GaugeValue, 77 | ageInSeconds, relname, 78 | ) 79 | } 80 | if err := rows.Err(); err != nil { 81 | return err 82 | } 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /collector/pg_stat_activity_autovacuum_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGStatActivityAutovacuumCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | inst := &instance{db: db} 32 | columns := []string{ 33 | "relname", 34 | "timestamp_seconds", 35 | } 36 | rows := sqlmock.NewRows(columns). 37 | AddRow("test", 3600) 38 | 39 | mock.ExpectQuery(sanitizeQuery(statActivityAutovacuumQuery)).WillReturnRows(rows) 40 | 41 | ch := make(chan prometheus.Metric) 42 | go func() { 43 | defer close(ch) 44 | c := PGStatActivityAutovacuumCollector{} 45 | 46 | if err := c.Update(context.Background(), inst, ch); err != nil { 47 | t.Errorf("Error calling PGStatActivityAutovacuumCollector.Update: %s", err) 48 | } 49 | }() 50 | expected := []MetricResult{ 51 | {labels: labelMap{"relname": "test"}, value: 3600, metricType: dto.MetricType_GAUGE}, 52 | } 53 | convey.Convey("Metrics comparison", t, func() { 54 | for _, expect := range expected { 55 | m := readMetric(<-ch) 56 | convey.So(expect, convey.ShouldResemble, m) 57 | } 58 | }) 59 | if err := mock.ExpectationsWereMet(); err != nil { 60 | t.Errorf("there were unfulfilled exceptions: %s", err) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /collector/pg_stat_bgwriter_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | "time" 19 | 20 | "github.com/DATA-DOG/go-sqlmock" 21 | "github.com/prometheus/client_golang/prometheus" 22 | dto "github.com/prometheus/client_model/go" 23 | "github.com/smartystreets/goconvey/convey" 24 | ) 25 | 26 | func TestPGStatBGWriterCollector(t *testing.T) { 27 | db, mock, err := sqlmock.New() 28 | if err != nil { 29 | t.Fatalf("Error opening a stub db connection: %s", err) 30 | } 31 | defer db.Close() 32 | 33 | inst := &instance{db: db} 34 | 35 | columns := []string{ 36 | "checkpoints_timed", 37 | "checkpoints_req", 38 | "checkpoint_write_time", 39 | "checkpoint_sync_time", 40 | "buffers_checkpoint", 41 | "buffers_clean", 42 | "maxwritten_clean", 43 | "buffers_backend", 44 | "buffers_backend_fsync", 45 | "buffers_alloc", 46 | "stats_reset"} 47 | 48 | srT, err := time.Parse("2006-01-02 15:04:05.00000-07", "2023-05-25 17:10:42.81132-07") 49 | if err != nil { 50 | t.Fatalf("Error parsing time: %s", err) 51 | } 52 | 53 | rows := sqlmock.NewRows(columns). 54 | AddRow(354, 4945, 289097744, 1242257, int64(3275602074), 89320867, 450139, 2034563757, 0, int64(2725688749), srT) 55 | mock.ExpectQuery(sanitizeQuery(statBGWriterQueryBefore17)).WillReturnRows(rows) 56 | 57 | ch := make(chan prometheus.Metric) 58 | go func() { 59 | defer close(ch) 60 | c := PGStatBGWriterCollector{} 61 | 62 | if err := c.Update(context.Background(), inst, ch); err != nil { 63 | t.Errorf("Error calling PGStatBGWriterCollector.Update: %s", err) 64 | } 65 | }() 66 | 67 | expected := []MetricResult{ 68 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 354}, 69 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 4945}, 70 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 289097744}, 71 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 1242257}, 72 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 3275602074}, 73 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 89320867}, 74 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 450139}, 75 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 2034563757}, 76 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 77 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 2725688749}, 78 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 1685059842}, 79 | } 80 | 81 | convey.Convey("Metrics comparison", t, func() { 82 | for _, expect := range expected { 83 | m := readMetric(<-ch) 84 | convey.So(expect, convey.ShouldResemble, m) 85 | } 86 | }) 87 | if err := mock.ExpectationsWereMet(); err != nil { 88 | t.Errorf("there were unfulfilled exceptions: %s", err) 89 | } 90 | } 91 | 92 | func TestPGStatBGWriterCollectorNullValues(t *testing.T) { 93 | db, mock, err := sqlmock.New() 94 | if err != nil { 95 | t.Fatalf("Error opening a stub db connection: %s", err) 96 | } 97 | defer db.Close() 98 | 99 | inst := &instance{db: db} 100 | 101 | columns := []string{ 102 | "checkpoints_timed", 103 | "checkpoints_req", 104 | "checkpoint_write_time", 105 | "checkpoint_sync_time", 106 | "buffers_checkpoint", 107 | "buffers_clean", 108 | "maxwritten_clean", 109 | "buffers_backend", 110 | "buffers_backend_fsync", 111 | "buffers_alloc", 112 | "stats_reset"} 113 | 114 | rows := sqlmock.NewRows(columns). 115 | AddRow(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) 116 | mock.ExpectQuery(sanitizeQuery(statBGWriterQueryBefore17)).WillReturnRows(rows) 117 | 118 | ch := make(chan prometheus.Metric) 119 | go func() { 120 | defer close(ch) 121 | c := PGStatBGWriterCollector{} 122 | 123 | if err := c.Update(context.Background(), inst, ch); err != nil { 124 | t.Errorf("Error calling PGStatBGWriterCollector.Update: %s", err) 125 | } 126 | }() 127 | 128 | expected := []MetricResult{ 129 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 130 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 131 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 132 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 133 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 134 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 135 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 136 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 137 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 138 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 139 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 140 | } 141 | 142 | convey.Convey("Metrics comparison", t, func() { 143 | for _, expect := range expected { 144 | m := readMetric(<-ch) 145 | convey.So(expect, convey.ShouldResemble, m) 146 | } 147 | }) 148 | if err := mock.ExpectationsWereMet(); err != nil { 149 | t.Errorf("there were unfulfilled exceptions: %s", err) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /collector/pg_stat_checkpointer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | "time" 19 | 20 | "github.com/DATA-DOG/go-sqlmock" 21 | "github.com/blang/semver/v4" 22 | "github.com/prometheus/client_golang/prometheus" 23 | dto "github.com/prometheus/client_model/go" 24 | "github.com/smartystreets/goconvey/convey" 25 | ) 26 | 27 | func TestPGStatCheckpointerCollector(t *testing.T) { 28 | db, mock, err := sqlmock.New() 29 | if err != nil { 30 | t.Fatalf("Error opening a stub db connection: %s", err) 31 | } 32 | defer db.Close() 33 | 34 | inst := &instance{db: db, version: semver.MustParse("17.0.0")} 35 | 36 | columns := []string{ 37 | "num_timed", 38 | "num_requested", 39 | "restartpoints_timed", 40 | "restartpoints_req", 41 | "restartpoints_done", 42 | "write_time", 43 | "sync_time", 44 | "buffers_written", 45 | "stats_reset"} 46 | 47 | srT, err := time.Parse("2006-01-02 15:04:05.00000-07", "2023-05-25 17:10:42.81132-07") 48 | if err != nil { 49 | t.Fatalf("Error parsing time: %s", err) 50 | } 51 | 52 | rows := sqlmock.NewRows(columns). 53 | AddRow(354, 4945, 289097744, 1242257, int64(3275602074), 89320867, 450139, 2034563757, srT) 54 | mock.ExpectQuery(sanitizeQuery(statCheckpointerQuery)).WillReturnRows(rows) 55 | 56 | ch := make(chan prometheus.Metric) 57 | go func() { 58 | defer close(ch) 59 | c := PGStatCheckpointerCollector{} 60 | 61 | if err := c.Update(context.Background(), inst, ch); err != nil { 62 | t.Errorf("Error calling PGStatCheckpointerCollector.Update: %s", err) 63 | } 64 | }() 65 | 66 | expected := []MetricResult{ 67 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 354}, 68 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 4945}, 69 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 289097744}, 70 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 1242257}, 71 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 3275602074}, 72 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 89320867}, 73 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 450139}, 74 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 2034563757}, 75 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 1685059842}, 76 | } 77 | 78 | convey.Convey("Metrics comparison", t, func() { 79 | for _, expect := range expected { 80 | m := readMetric(<-ch) 81 | convey.So(expect, convey.ShouldResemble, m) 82 | } 83 | }) 84 | if err := mock.ExpectationsWereMet(); err != nil { 85 | t.Errorf("there were unfulfilled exceptions: %s", err) 86 | } 87 | } 88 | 89 | func TestPGStatCheckpointerCollectorNullValues(t *testing.T) { 90 | db, mock, err := sqlmock.New() 91 | if err != nil { 92 | t.Fatalf("Error opening a stub db connection: %s", err) 93 | } 94 | defer db.Close() 95 | 96 | inst := &instance{db: db, version: semver.MustParse("17.0.0")} 97 | 98 | columns := []string{ 99 | "num_timed", 100 | "num_requested", 101 | "restartpoints_timed", 102 | "restartpoints_req", 103 | "restartpoints_done", 104 | "write_time", 105 | "sync_time", 106 | "buffers_written", 107 | "stats_reset"} 108 | 109 | rows := sqlmock.NewRows(columns). 110 | AddRow(nil, nil, nil, nil, nil, nil, nil, nil, nil) 111 | mock.ExpectQuery(sanitizeQuery(statCheckpointerQuery)).WillReturnRows(rows) 112 | 113 | ch := make(chan prometheus.Metric) 114 | go func() { 115 | defer close(ch) 116 | c := PGStatCheckpointerCollector{} 117 | 118 | if err := c.Update(context.Background(), inst, ch); err != nil { 119 | t.Errorf("Error calling PGStatCheckpointerCollector.Update: %s", err) 120 | } 121 | }() 122 | 123 | expected := []MetricResult{ 124 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 125 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 126 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 127 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 128 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 129 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 130 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 131 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 132 | {labels: labelMap{}, metricType: dto.MetricType_COUNTER, value: 0}, 133 | } 134 | 135 | convey.Convey("Metrics comparison", t, func() { 136 | for _, expect := range expected { 137 | m := readMetric(<-ch) 138 | convey.So(expect, convey.ShouldResemble, m) 139 | } 140 | }) 141 | if err := mock.ExpectationsWereMet(); err != nil { 142 | t.Errorf("there were unfulfilled exceptions: %s", err) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /collector/pg_stat_progress_vacuum_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGStatProgressVacuumCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | columns := []string{ 35 | "datname", "relname", "phase", "heap_blks_total", "heap_blks_scanned", 36 | "heap_blks_vacuumed", "index_vacuum_count", "max_dead_tuples", "num_dead_tuples", 37 | } 38 | 39 | rows := sqlmock.NewRows(columns).AddRow( 40 | "postgres", "a_table", 3, 3000, 400, 200, 2, 500, 123) 41 | 42 | mock.ExpectQuery(sanitizeQuery(statProgressVacuumQuery)).WillReturnRows(rows) 43 | 44 | ch := make(chan prometheus.Metric) 45 | go func() { 46 | defer close(ch) 47 | c := PGStatProgressVacuumCollector{} 48 | 49 | if err := c.Update(context.Background(), inst, ch); err != nil { 50 | t.Errorf("Error calling PGStatProgressVacuumCollector.Update; %+v", err) 51 | } 52 | }() 53 | 54 | expected := []MetricResult{ 55 | {labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "initializing"}, metricType: dto.MetricType_GAUGE, value: 0}, 56 | {labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "scanning heap"}, metricType: dto.MetricType_GAUGE, value: 0}, 57 | {labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "vacuuming indexes"}, metricType: dto.MetricType_GAUGE, value: 0}, 58 | {labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "vacuuming heap"}, metricType: dto.MetricType_GAUGE, value: 1}, 59 | {labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "cleaning up indexes"}, metricType: dto.MetricType_GAUGE, value: 0}, 60 | {labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "truncating heap"}, metricType: dto.MetricType_GAUGE, value: 0}, 61 | {labels: labelMap{"datname": "postgres", "relname": "a_table", "phase": "performing final cleanup"}, metricType: dto.MetricType_GAUGE, value: 0}, 62 | {labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 3000}, 63 | {labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 400}, 64 | {labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 200}, 65 | {labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 2}, 66 | {labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 500}, 67 | {labels: labelMap{"datname": "postgres", "relname": "a_table"}, metricType: dto.MetricType_GAUGE, value: 123}, 68 | } 69 | 70 | convey.Convey("Metrics comparison", t, func() { 71 | for _, expect := range expected { 72 | m := readMetric(<-ch) 73 | convey.So(m, convey.ShouldResemble, expect) 74 | } 75 | }) 76 | if err := mock.ExpectationsWereMet(); err != nil { 77 | t.Errorf("There were unfulfilled exceptions: %+v", err) 78 | } 79 | } 80 | 81 | func TestPGStatProgressVacuumCollectorNullValues(t *testing.T) { 82 | db, mock, err := sqlmock.New() 83 | if err != nil { 84 | t.Fatalf("Error opening a stub db connection: %s", err) 85 | } 86 | defer db.Close() 87 | 88 | inst := &instance{db: db} 89 | 90 | columns := []string{ 91 | "datname", "relname", "phase", "heap_blks_total", "heap_blks_scanned", 92 | "heap_blks_vacuumed", "index_vacuum_count", "max_dead_tuples", "num_dead_tuples", 93 | } 94 | 95 | rows := sqlmock.NewRows(columns).AddRow( 96 | "postgres", nil, nil, nil, nil, nil, nil, nil, nil) 97 | 98 | mock.ExpectQuery(sanitizeQuery(statProgressVacuumQuery)).WillReturnRows(rows) 99 | 100 | ch := make(chan prometheus.Metric) 101 | go func() { 102 | defer close(ch) 103 | c := PGStatProgressVacuumCollector{} 104 | 105 | if err := c.Update(context.Background(), inst, ch); err != nil { 106 | t.Errorf("Error calling PGStatProgressVacuumCollector.Update; %+v", err) 107 | } 108 | }() 109 | 110 | expected := []MetricResult{ 111 | {labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "initializing"}, metricType: dto.MetricType_GAUGE, value: 0}, 112 | {labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "scanning heap"}, metricType: dto.MetricType_GAUGE, value: 0}, 113 | {labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "vacuuming indexes"}, metricType: dto.MetricType_GAUGE, value: 0}, 114 | {labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "vacuuming heap"}, metricType: dto.MetricType_GAUGE, value: 0}, 115 | {labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "cleaning up indexes"}, metricType: dto.MetricType_GAUGE, value: 0}, 116 | {labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "truncating heap"}, metricType: dto.MetricType_GAUGE, value: 0}, 117 | {labels: labelMap{"datname": "postgres", "relname": "unknown", "phase": "performing final cleanup"}, metricType: dto.MetricType_GAUGE, value: 0}, 118 | {labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0}, 119 | {labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0}, 120 | {labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0}, 121 | {labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0}, 122 | {labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0}, 123 | {labels: labelMap{"datname": "postgres", "relname": "unknown"}, metricType: dto.MetricType_GAUGE, value: 0}, 124 | } 125 | 126 | convey.Convey("Metrics comparison", t, func() { 127 | for _, expect := range expected { 128 | m := readMetric(<-ch) 129 | convey.So(expect, convey.ShouldResemble, m) 130 | } 131 | }) 132 | if err := mock.ExpectationsWereMet(); err != nil { 133 | t.Errorf("There were unfulfilled exceptions: %+v", err) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /collector/pg_stat_walreceiver_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "fmt" 18 | "testing" 19 | 20 | "github.com/DATA-DOG/go-sqlmock" 21 | "github.com/prometheus/client_golang/prometheus" 22 | dto "github.com/prometheus/client_model/go" 23 | "github.com/smartystreets/goconvey/convey" 24 | ) 25 | 26 | var queryWithFlushedLSN = fmt.Sprintf(pgStatWalReceiverQueryTemplate, "(flushed_lsn - '0/0') % (2^52)::bigint as flushed_lsn,\n") 27 | var queryWithNoFlushedLSN = fmt.Sprintf(pgStatWalReceiverQueryTemplate, "") 28 | 29 | func TestPGStatWalReceiverCollectorWithFlushedLSN(t *testing.T) { 30 | db, mock, err := sqlmock.New() 31 | if err != nil { 32 | t.Fatalf("Error opening a stub db connection: %s", err) 33 | } 34 | defer db.Close() 35 | 36 | inst := &instance{db: db} 37 | infoSchemaColumns := []string{ 38 | "column_name", 39 | } 40 | 41 | infoSchemaRows := sqlmock.NewRows(infoSchemaColumns). 42 | AddRow( 43 | "flushed_lsn", 44 | ) 45 | 46 | mock.ExpectQuery(sanitizeQuery(pgStatWalColumnQuery)).WillReturnRows(infoSchemaRows) 47 | 48 | columns := []string{ 49 | "upstream_host", 50 | "slot_name", 51 | "status", 52 | "receive_start_lsn", 53 | "receive_start_tli", 54 | "flushed_lsn", 55 | "received_tli", 56 | "last_msg_send_time", 57 | "last_msg_receipt_time", 58 | "latest_end_lsn", 59 | "latest_end_time", 60 | "upstream_node", 61 | } 62 | rows := sqlmock.NewRows(columns). 63 | AddRow( 64 | "foo", 65 | "bar", 66 | "stopping", 67 | int64(1200668684563608), 68 | 1687321285, 69 | int64(1200668684563609), 70 | 1687321280, 71 | 1687321275, 72 | 1687321276, 73 | int64(1200668684563610), 74 | 1687321277, 75 | 5, 76 | ) 77 | 78 | mock.ExpectQuery(sanitizeQuery(queryWithFlushedLSN)).WillReturnRows(rows) 79 | 80 | ch := make(chan prometheus.Metric) 81 | go func() { 82 | defer close(ch) 83 | c := PGStatWalReceiverCollector{} 84 | 85 | if err := c.Update(context.Background(), inst, ch); err != nil { 86 | t.Errorf("Error calling PgStatWalReceiverCollector.Update: %s", err) 87 | } 88 | }() 89 | expected := []MetricResult{ 90 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1200668684563608, metricType: dto.MetricType_COUNTER}, 91 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1687321285, metricType: dto.MetricType_GAUGE}, 92 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1200668684563609, metricType: dto.MetricType_COUNTER}, 93 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1687321280, metricType: dto.MetricType_GAUGE}, 94 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1687321275, metricType: dto.MetricType_COUNTER}, 95 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1687321276, metricType: dto.MetricType_COUNTER}, 96 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1200668684563610, metricType: dto.MetricType_COUNTER}, 97 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 1687321277, metricType: dto.MetricType_COUNTER}, 98 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "stopping"}, value: 5, metricType: dto.MetricType_GAUGE}, 99 | } 100 | convey.Convey("Metrics comparison", t, func() { 101 | for _, expect := range expected { 102 | m := readMetric(<-ch) 103 | convey.So(expect, convey.ShouldResemble, m) 104 | } 105 | }) 106 | if err := mock.ExpectationsWereMet(); err != nil { 107 | t.Errorf("there were unfulfilled exceptions: %s", err) 108 | } 109 | 110 | } 111 | 112 | func TestPGStatWalReceiverCollectorWithNoFlushedLSN(t *testing.T) { 113 | db, mock, err := sqlmock.New() 114 | if err != nil { 115 | t.Fatalf("Error opening a stub db connection: %s", err) 116 | } 117 | defer db.Close() 118 | 119 | inst := &instance{db: db} 120 | infoSchemaColumns := []string{ 121 | "column_name", 122 | } 123 | 124 | infoSchemaRows := sqlmock.NewRows(infoSchemaColumns) 125 | 126 | mock.ExpectQuery(sanitizeQuery(pgStatWalColumnQuery)).WillReturnRows(infoSchemaRows) 127 | 128 | columns := []string{ 129 | "upstream_host", 130 | "slot_name", 131 | "status", 132 | "receive_start_lsn", 133 | "receive_start_tli", 134 | "received_tli", 135 | "last_msg_send_time", 136 | "last_msg_receipt_time", 137 | "latest_end_lsn", 138 | "latest_end_time", 139 | "upstream_node", 140 | } 141 | rows := sqlmock.NewRows(columns). 142 | AddRow( 143 | "foo", 144 | "bar", 145 | "starting", 146 | int64(1200668684563608), 147 | 1687321285, 148 | 1687321280, 149 | 1687321275, 150 | 1687321276, 151 | int64(1200668684563610), 152 | 1687321277, 153 | 5, 154 | ) 155 | mock.ExpectQuery(sanitizeQuery(queryWithNoFlushedLSN)).WillReturnRows(rows) 156 | 157 | ch := make(chan prometheus.Metric) 158 | go func() { 159 | defer close(ch) 160 | c := PGStatWalReceiverCollector{} 161 | 162 | if err := c.Update(context.Background(), inst, ch); err != nil { 163 | t.Errorf("Error calling PgStatWalReceiverCollector.Update: %s", err) 164 | } 165 | }() 166 | expected := []MetricResult{ 167 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 1200668684563608, metricType: dto.MetricType_COUNTER}, 168 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 1687321285, metricType: dto.MetricType_GAUGE}, 169 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 1687321280, metricType: dto.MetricType_GAUGE}, 170 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 1687321275, metricType: dto.MetricType_COUNTER}, 171 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 1687321276, metricType: dto.MetricType_COUNTER}, 172 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 1200668684563610, metricType: dto.MetricType_COUNTER}, 173 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 1687321277, metricType: dto.MetricType_COUNTER}, 174 | {labels: labelMap{"upstream_host": "foo", "slot_name": "bar", "status": "starting"}, value: 5, metricType: dto.MetricType_GAUGE}, 175 | } 176 | convey.Convey("Metrics comparison", t, func() { 177 | for _, expect := range expected { 178 | m := readMetric(<-ch) 179 | convey.So(expect, convey.ShouldResemble, m) 180 | } 181 | }) 182 | if err := mock.ExpectationsWereMet(); err != nil { 183 | t.Errorf("there were unfulfilled exceptions: %s", err) 184 | } 185 | 186 | } 187 | -------------------------------------------------------------------------------- /collector/pg_statio_user_indexes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "database/sql" 18 | "log/slog" 19 | 20 | "github.com/prometheus/client_golang/prometheus" 21 | ) 22 | 23 | func init() { 24 | registerCollector(statioUserIndexesSubsystem, defaultDisabled, NewPGStatioUserIndexesCollector) 25 | } 26 | 27 | type PGStatioUserIndexesCollector struct { 28 | log *slog.Logger 29 | } 30 | 31 | const statioUserIndexesSubsystem = "statio_user_indexes" 32 | 33 | func NewPGStatioUserIndexesCollector(config collectorConfig) (Collector, error) { 34 | return &PGStatioUserIndexesCollector{log: config.logger}, nil 35 | } 36 | 37 | var ( 38 | statioUserIndexesIdxBlksRead = prometheus.NewDesc( 39 | prometheus.BuildFQName(namespace, statioUserIndexesSubsystem, "idx_blks_read_total"), 40 | "Number of disk blocks read from this index", 41 | []string{"schemaname", "relname", "indexrelname"}, 42 | prometheus.Labels{}, 43 | ) 44 | statioUserIndexesIdxBlksHit = prometheus.NewDesc( 45 | prometheus.BuildFQName(namespace, statioUserIndexesSubsystem, "idx_blks_hit_total"), 46 | "Number of buffer hits in this index", 47 | []string{"schemaname", "relname", "indexrelname"}, 48 | prometheus.Labels{}, 49 | ) 50 | 51 | statioUserIndexesQuery = ` 52 | SELECT 53 | schemaname, 54 | relname, 55 | indexrelname, 56 | idx_blks_read, 57 | idx_blks_hit 58 | FROM pg_statio_user_indexes 59 | ` 60 | ) 61 | 62 | func (c *PGStatioUserIndexesCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 63 | db := instance.getDB() 64 | rows, err := db.QueryContext(ctx, 65 | statioUserIndexesQuery) 66 | 67 | if err != nil { 68 | return err 69 | } 70 | defer rows.Close() 71 | for rows.Next() { 72 | var schemaname, relname, indexrelname sql.NullString 73 | var idxBlksRead, idxBlksHit sql.NullFloat64 74 | 75 | if err := rows.Scan(&schemaname, &relname, &indexrelname, &idxBlksRead, &idxBlksHit); err != nil { 76 | return err 77 | } 78 | schemanameLabel := "unknown" 79 | if schemaname.Valid { 80 | schemanameLabel = schemaname.String 81 | } 82 | relnameLabel := "unknown" 83 | if relname.Valid { 84 | relnameLabel = relname.String 85 | } 86 | indexrelnameLabel := "unknown" 87 | if indexrelname.Valid { 88 | indexrelnameLabel = indexrelname.String 89 | } 90 | labels := []string{schemanameLabel, relnameLabel, indexrelnameLabel} 91 | 92 | idxBlksReadMetric := 0.0 93 | if idxBlksRead.Valid { 94 | idxBlksReadMetric = idxBlksRead.Float64 95 | } 96 | ch <- prometheus.MustNewConstMetric( 97 | statioUserIndexesIdxBlksRead, 98 | prometheus.CounterValue, 99 | idxBlksReadMetric, 100 | labels..., 101 | ) 102 | 103 | idxBlksHitMetric := 0.0 104 | if idxBlksHit.Valid { 105 | idxBlksHitMetric = idxBlksHit.Float64 106 | } 107 | ch <- prometheus.MustNewConstMetric( 108 | statioUserIndexesIdxBlksHit, 109 | prometheus.CounterValue, 110 | idxBlksHitMetric, 111 | labels..., 112 | ) 113 | } 114 | if err := rows.Err(); err != nil { 115 | return err 116 | } 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /collector/pg_statio_user_indexes_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPgStatioUserIndexesCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | inst := &instance{db: db} 32 | columns := []string{ 33 | "schemaname", 34 | "relname", 35 | "indexrelname", 36 | "idx_blks_read", 37 | "idx_blks_hit", 38 | } 39 | rows := sqlmock.NewRows(columns). 40 | AddRow("public", "pgtest_accounts", "pgtest_accounts_pkey", 8, 9) 41 | 42 | mock.ExpectQuery(sanitizeQuery(statioUserIndexesQuery)).WillReturnRows(rows) 43 | 44 | ch := make(chan prometheus.Metric) 45 | go func() { 46 | defer close(ch) 47 | c := PGStatioUserIndexesCollector{} 48 | 49 | if err := c.Update(context.Background(), inst, ch); err != nil { 50 | t.Errorf("Error calling PGStatioUserIndexesCollector.Update: %s", err) 51 | } 52 | }() 53 | expected := []MetricResult{ 54 | {labels: labelMap{"schemaname": "public", "relname": "pgtest_accounts", "indexrelname": "pgtest_accounts_pkey"}, value: 8, metricType: dto.MetricType_COUNTER}, 55 | {labels: labelMap{"schemaname": "public", "relname": "pgtest_accounts", "indexrelname": "pgtest_accounts_pkey"}, value: 9, metricType: dto.MetricType_COUNTER}, 56 | } 57 | convey.Convey("Metrics comparison", t, func() { 58 | for _, expect := range expected { 59 | m := readMetric(<-ch) 60 | convey.So(expect, convey.ShouldResemble, m) 61 | } 62 | }) 63 | if err := mock.ExpectationsWereMet(); err != nil { 64 | t.Errorf("there were unfulfilled exceptions: %s", err) 65 | } 66 | } 67 | 68 | func TestPgStatioUserIndexesCollectorNull(t *testing.T) { 69 | db, mock, err := sqlmock.New() 70 | if err != nil { 71 | t.Fatalf("Error opening a stub db connection: %s", err) 72 | } 73 | defer db.Close() 74 | inst := &instance{db: db} 75 | columns := []string{ 76 | "schemaname", 77 | "relname", 78 | "indexrelname", 79 | "idx_blks_read", 80 | "idx_blks_hit", 81 | } 82 | rows := sqlmock.NewRows(columns). 83 | AddRow(nil, nil, nil, nil, nil) 84 | 85 | mock.ExpectQuery(sanitizeQuery(statioUserIndexesQuery)).WillReturnRows(rows) 86 | 87 | ch := make(chan prometheus.Metric) 88 | go func() { 89 | defer close(ch) 90 | c := PGStatioUserIndexesCollector{} 91 | 92 | if err := c.Update(context.Background(), inst, ch); err != nil { 93 | t.Errorf("Error calling PGStatioUserIndexesCollector.Update: %s", err) 94 | } 95 | }() 96 | expected := []MetricResult{ 97 | {labels: labelMap{"schemaname": "unknown", "relname": "unknown", "indexrelname": "unknown"}, value: 0, metricType: dto.MetricType_COUNTER}, 98 | {labels: labelMap{"schemaname": "unknown", "relname": "unknown", "indexrelname": "unknown"}, value: 0, metricType: dto.MetricType_COUNTER}, 99 | } 100 | convey.Convey("Metrics comparison", t, func() { 101 | for _, expect := range expected { 102 | m := readMetric(<-ch) 103 | convey.So(expect, convey.ShouldResemble, m) 104 | } 105 | }) 106 | if err := mock.ExpectationsWereMet(); err != nil { 107 | t.Errorf("there were unfulfilled exceptions: %s", err) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /collector/pg_statio_user_tables_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGStatIOUserTablesCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | columns := []string{ 35 | "datname", 36 | "schemaname", 37 | "relname", 38 | "heap_blks_read", 39 | "heap_blks_hit", 40 | "idx_blks_read", 41 | "idx_blks_hit", 42 | "toast_blks_read", 43 | "toast_blks_hit", 44 | "tidx_blks_read", 45 | "tidx_blks_hit", 46 | } 47 | rows := sqlmock.NewRows(columns). 48 | AddRow("postgres", 49 | "public", 50 | "a_table", 51 | 1, 52 | 2, 53 | 3, 54 | 4, 55 | 5, 56 | 6, 57 | 7, 58 | 8) 59 | mock.ExpectQuery(sanitizeQuery(statioUserTablesQuery)).WillReturnRows(rows) 60 | ch := make(chan prometheus.Metric) 61 | go func() { 62 | defer close(ch) 63 | c := PGStatIOUserTablesCollector{} 64 | 65 | if err := c.Update(context.Background(), inst, ch); err != nil { 66 | t.Errorf("Error calling PGStatIOUserTablesCollector.Update: %s", err) 67 | } 68 | }() 69 | 70 | expected := []MetricResult{ 71 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 1}, 72 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 2}, 73 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 3}, 74 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 4}, 75 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 5}, 76 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 6}, 77 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 7}, 78 | {labels: labelMap{"datname": "postgres", "schemaname": "public", "relname": "a_table"}, metricType: dto.MetricType_COUNTER, value: 8}, 79 | } 80 | 81 | convey.Convey("Metrics comparison", t, func() { 82 | for _, expect := range expected { 83 | m := readMetric(<-ch) 84 | convey.So(expect, convey.ShouldResemble, m) 85 | } 86 | }) 87 | if err := mock.ExpectationsWereMet(); err != nil { 88 | t.Errorf("there were unfulfilled exceptions: %s", err) 89 | } 90 | } 91 | 92 | func TestPGStatIOUserTablesCollectorNullValues(t *testing.T) { 93 | db, mock, err := sqlmock.New() 94 | if err != nil { 95 | t.Fatalf("Error opening a stub db connection: %s", err) 96 | } 97 | defer db.Close() 98 | 99 | inst := &instance{db: db} 100 | 101 | columns := []string{ 102 | "datname", 103 | "schemaname", 104 | "relname", 105 | "heap_blks_read", 106 | "heap_blks_hit", 107 | "idx_blks_read", 108 | "idx_blks_hit", 109 | "toast_blks_read", 110 | "toast_blks_hit", 111 | "tidx_blks_read", 112 | "tidx_blks_hit", 113 | } 114 | rows := sqlmock.NewRows(columns). 115 | AddRow(nil, 116 | nil, 117 | nil, 118 | nil, 119 | nil, 120 | nil, 121 | nil, 122 | nil, 123 | nil, 124 | nil, 125 | nil) 126 | mock.ExpectQuery(sanitizeQuery(statioUserTablesQuery)).WillReturnRows(rows) 127 | ch := make(chan prometheus.Metric) 128 | go func() { 129 | defer close(ch) 130 | c := PGStatIOUserTablesCollector{} 131 | 132 | if err := c.Update(context.Background(), inst, ch); err != nil { 133 | t.Errorf("Error calling PGStatIOUserTablesCollector.Update: %s", err) 134 | } 135 | }() 136 | 137 | expected := []MetricResult{ 138 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 139 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 140 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 141 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 142 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 143 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 144 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 145 | {labels: labelMap{"datname": "unknown", "schemaname": "unknown", "relname": "unknown"}, metricType: dto.MetricType_COUNTER, value: 0}, 146 | } 147 | 148 | convey.Convey("Metrics comparison", t, func() { 149 | for _, expect := range expected { 150 | m := readMetric(<-ch) 151 | convey.So(expect, convey.ShouldResemble, m) 152 | } 153 | }) 154 | if err := mock.ExpectationsWereMet(); err != nil { 155 | t.Errorf("there were unfulfilled exceptions: %s", err) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /collector/pg_wal.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | 19 | "github.com/prometheus/client_golang/prometheus" 20 | ) 21 | 22 | const walSubsystem = "wal" 23 | 24 | func init() { 25 | registerCollector(walSubsystem, defaultEnabled, NewPGWALCollector) 26 | } 27 | 28 | type PGWALCollector struct { 29 | } 30 | 31 | func NewPGWALCollector(config collectorConfig) (Collector, error) { 32 | return &PGWALCollector{}, nil 33 | } 34 | 35 | var ( 36 | pgWALSegments = prometheus.NewDesc( 37 | prometheus.BuildFQName( 38 | namespace, 39 | walSubsystem, 40 | "segments", 41 | ), 42 | "Number of WAL segments", 43 | []string{}, nil, 44 | ) 45 | pgWALSize = prometheus.NewDesc( 46 | prometheus.BuildFQName( 47 | namespace, 48 | walSubsystem, 49 | "size_bytes", 50 | ), 51 | "Total size of WAL segments", 52 | []string{}, nil, 53 | ) 54 | 55 | pgWALQuery = ` 56 | SELECT 57 | COUNT(*) AS segments, 58 | SUM(size) AS size 59 | FROM pg_ls_waldir() 60 | WHERE name ~ '^[0-9A-F]{24}$'` 61 | ) 62 | 63 | func (c PGWALCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 64 | db := instance.getDB() 65 | row := db.QueryRowContext(ctx, 66 | pgWALQuery, 67 | ) 68 | 69 | var segments uint64 70 | var size uint64 71 | err := row.Scan(&segments, &size) 72 | if err != nil { 73 | return err 74 | } 75 | ch <- prometheus.MustNewConstMetric( 76 | pgWALSegments, 77 | prometheus.GaugeValue, float64(segments), 78 | ) 79 | ch <- prometheus.MustNewConstMetric( 80 | pgWALSize, 81 | prometheus.GaugeValue, float64(size), 82 | ) 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /collector/pg_wal_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPgWALCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | 32 | inst := &instance{db: db} 33 | 34 | columns := []string{"segments", "size"} 35 | rows := sqlmock.NewRows(columns). 36 | AddRow(47, 788529152) 37 | mock.ExpectQuery(sanitizeQuery(pgWALQuery)).WillReturnRows(rows) 38 | 39 | ch := make(chan prometheus.Metric) 40 | go func() { 41 | defer close(ch) 42 | c := PGWALCollector{} 43 | 44 | if err := c.Update(context.Background(), inst, ch); err != nil { 45 | t.Errorf("Error calling PGWALCollector.Update: %s", err) 46 | } 47 | }() 48 | 49 | expected := []MetricResult{ 50 | {labels: labelMap{}, value: 47, metricType: dto.MetricType_GAUGE}, 51 | {labels: labelMap{}, value: 788529152, metricType: dto.MetricType_GAUGE}, 52 | } 53 | 54 | convey.Convey("Metrics comparison", t, func() { 55 | for _, expect := range expected { 56 | m := readMetric(<-ch) 57 | convey.So(expect, convey.ShouldResemble, m) 58 | } 59 | }) 60 | if err := mock.ExpectationsWereMet(); err != nil { 61 | t.Errorf("there were unfulfilled exceptions: %s", err) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /collector/pg_xlog_location.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "log/slog" 19 | 20 | "github.com/blang/semver/v4" 21 | "github.com/prometheus/client_golang/prometheus" 22 | ) 23 | 24 | const xlogLocationSubsystem = "xlog_location" 25 | 26 | func init() { 27 | registerCollector(xlogLocationSubsystem, defaultDisabled, NewPGXlogLocationCollector) 28 | } 29 | 30 | type PGXlogLocationCollector struct { 31 | log *slog.Logger 32 | } 33 | 34 | func NewPGXlogLocationCollector(config collectorConfig) (Collector, error) { 35 | return &PGXlogLocationCollector{log: config.logger}, nil 36 | } 37 | 38 | var ( 39 | xlogLocationBytes = prometheus.NewDesc( 40 | prometheus.BuildFQName(namespace, xlogLocationSubsystem, "bytes"), 41 | "Postgres LSN (log sequence number) being generated on primary or replayed on replica (truncated to low 52 bits)", 42 | []string{}, 43 | prometheus.Labels{}, 44 | ) 45 | 46 | xlogLocationQuery = ` 47 | SELECT CASE 48 | WHEN pg_is_in_recovery() THEN (pg_last_xlog_replay_location() - '0/0') % (2^52)::bigint 49 | ELSE (pg_current_xlog_location() - '0/0') % (2^52)::bigint 50 | END AS bytes 51 | ` 52 | ) 53 | 54 | func (c PGXlogLocationCollector) Update(ctx context.Context, instance *instance, ch chan<- prometheus.Metric) error { 55 | db := instance.getDB() 56 | 57 | // xlog was renmaed to WAL in PostgreSQL 10 58 | // https://wiki.postgresql.org/wiki/New_in_postgres_10#Renaming_of_.22xlog.22_to_.22wal.22_Globally_.28and_location.2Flsn.29 59 | after10 := instance.version.Compare(semver.MustParse("10.0.0")) 60 | if after10 >= 0 { 61 | c.log.Warn("xlog_location collector is not available on PostgreSQL >= 10.0.0, skipping") 62 | return nil 63 | } 64 | 65 | rows, err := db.QueryContext(ctx, 66 | xlogLocationQuery) 67 | 68 | if err != nil { 69 | return err 70 | } 71 | defer rows.Close() 72 | 73 | for rows.Next() { 74 | var bytes float64 75 | 76 | if err := rows.Scan(&bytes); err != nil { 77 | return err 78 | } 79 | 80 | ch <- prometheus.MustNewConstMetric( 81 | xlogLocationBytes, 82 | prometheus.GaugeValue, 83 | bytes, 84 | ) 85 | } 86 | if err := rows.Err(); err != nil { 87 | return err 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /collector/pg_xlog_location_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | package collector 14 | 15 | import ( 16 | "context" 17 | "testing" 18 | 19 | "github.com/DATA-DOG/go-sqlmock" 20 | "github.com/prometheus/client_golang/prometheus" 21 | dto "github.com/prometheus/client_model/go" 22 | "github.com/smartystreets/goconvey/convey" 23 | ) 24 | 25 | func TestPGXlogLocationCollector(t *testing.T) { 26 | db, mock, err := sqlmock.New() 27 | if err != nil { 28 | t.Fatalf("Error opening a stub db connection: %s", err) 29 | } 30 | defer db.Close() 31 | inst := &instance{db: db} 32 | columns := []string{ 33 | "bytes", 34 | } 35 | rows := sqlmock.NewRows(columns). 36 | AddRow(53401) 37 | 38 | mock.ExpectQuery(sanitizeQuery(xlogLocationQuery)).WillReturnRows(rows) 39 | 40 | ch := make(chan prometheus.Metric) 41 | go func() { 42 | defer close(ch) 43 | c := PGXlogLocationCollector{} 44 | 45 | if err := c.Update(context.Background(), inst, ch); err != nil { 46 | t.Errorf("Error calling PGXlogLocationCollector.Update: %s", err) 47 | } 48 | }() 49 | expected := []MetricResult{ 50 | {labels: labelMap{}, value: 53401, metricType: dto.MetricType_GAUGE}, 51 | } 52 | convey.Convey("Metrics comparison", t, func() { 53 | for _, expect := range expected { 54 | m := readMetric(<-ch) 55 | convey.So(expect, convey.ShouldResemble, m) 56 | } 57 | }) 58 | if err := mock.ExpectationsWereMet(); err != nil { 59 | t.Errorf("there were unfulfilled exceptions: %s", err) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /collector/probe.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package collector 15 | 16 | import ( 17 | "context" 18 | "log/slog" 19 | "sync" 20 | 21 | "github.com/prometheus-community/postgres_exporter/config" 22 | "github.com/prometheus/client_golang/prometheus" 23 | ) 24 | 25 | type ProbeCollector struct { 26 | registry *prometheus.Registry 27 | collectors map[string]Collector 28 | logger *slog.Logger 29 | instance *instance 30 | } 31 | 32 | func NewProbeCollector(logger *slog.Logger, excludeDatabases []string, registry *prometheus.Registry, dsn config.DSN) (*ProbeCollector, error) { 33 | collectors := make(map[string]Collector) 34 | initiatedCollectorsMtx.Lock() 35 | defer initiatedCollectorsMtx.Unlock() 36 | for key, enabled := range collectorState { 37 | // TODO: Handle filters 38 | // if !*enabled || (len(f) > 0 && !f[key]) { 39 | // continue 40 | // } 41 | if !*enabled { 42 | continue 43 | } 44 | if collector, ok := initiatedCollectors[key]; ok { 45 | collectors[key] = collector 46 | } else { 47 | collector, err := factories[key]( 48 | collectorConfig{ 49 | logger: logger.With("collector", key), 50 | excludeDatabases: excludeDatabases, 51 | }) 52 | if err != nil { 53 | return nil, err 54 | } 55 | collectors[key] = collector 56 | initiatedCollectors[key] = collector 57 | } 58 | } 59 | 60 | instance, err := newInstance(dsn.GetConnectionString()) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | return &ProbeCollector{ 66 | registry: registry, 67 | collectors: collectors, 68 | logger: logger, 69 | instance: instance, 70 | }, nil 71 | } 72 | 73 | func (pc *ProbeCollector) Describe(ch chan<- *prometheus.Desc) { 74 | } 75 | 76 | func (pc *ProbeCollector) Collect(ch chan<- prometheus.Metric) { 77 | // Set up the database connection for the collector. 78 | err := pc.instance.setup() 79 | if err != nil { 80 | pc.logger.Error("Error opening connection to database", "err", err) 81 | return 82 | } 83 | defer pc.instance.Close() 84 | 85 | wg := sync.WaitGroup{} 86 | wg.Add(len(pc.collectors)) 87 | for name, c := range pc.collectors { 88 | go func(name string, c Collector) { 89 | execute(context.TODO(), name, c, pc.instance, ch, pc.logger) 90 | wg.Done() 91 | }(name, c) 92 | } 93 | wg.Wait() 94 | } 95 | 96 | func (pc *ProbeCollector) Close() error { 97 | return pc.instance.Close() 98 | } 99 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package config 15 | 16 | import ( 17 | "fmt" 18 | "log/slog" 19 | "os" 20 | "sync" 21 | 22 | "github.com/prometheus/client_golang/prometheus" 23 | "github.com/prometheus/client_golang/prometheus/promauto" 24 | "gopkg.in/yaml.v3" 25 | ) 26 | 27 | var ( 28 | configReloadSuccess = promauto.NewGauge(prometheus.GaugeOpts{ 29 | Namespace: "postgres_exporter", 30 | Name: "config_last_reload_successful", 31 | Help: "Postgres exporter config loaded successfully.", 32 | }) 33 | 34 | configReloadSeconds = promauto.NewGauge(prometheus.GaugeOpts{ 35 | Namespace: "postgres_exporter", 36 | Name: "config_last_reload_success_timestamp_seconds", 37 | Help: "Timestamp of the last successful configuration reload.", 38 | }) 39 | ) 40 | 41 | type Config struct { 42 | AuthModules map[string]AuthModule `yaml:"auth_modules"` 43 | } 44 | 45 | type AuthModule struct { 46 | Type string `yaml:"type"` 47 | UserPass UserPass `yaml:"userpass,omitempty"` 48 | // Add alternative auth modules here 49 | Options map[string]string `yaml:"options"` 50 | } 51 | 52 | type UserPass struct { 53 | Username string `yaml:"username"` 54 | Password string `yaml:"password"` 55 | } 56 | 57 | type Handler struct { 58 | sync.RWMutex 59 | Config *Config 60 | } 61 | 62 | func (ch *Handler) GetConfig() *Config { 63 | ch.RLock() 64 | defer ch.RUnlock() 65 | return ch.Config 66 | } 67 | 68 | func (ch *Handler) ReloadConfig(f string, logger *slog.Logger) error { 69 | config := &Config{} 70 | var err error 71 | defer func() { 72 | if err != nil { 73 | configReloadSuccess.Set(0) 74 | } else { 75 | configReloadSuccess.Set(1) 76 | configReloadSeconds.SetToCurrentTime() 77 | } 78 | }() 79 | 80 | yamlReader, err := os.Open(f) 81 | if err != nil { 82 | return fmt.Errorf("error opening config file %q: %s", f, err) 83 | } 84 | defer yamlReader.Close() 85 | decoder := yaml.NewDecoder(yamlReader) 86 | decoder.KnownFields(true) 87 | 88 | if err = decoder.Decode(config); err != nil { 89 | return fmt.Errorf("error parsing config file %q: %s", f, err) 90 | } 91 | 92 | ch.Lock() 93 | ch.Config = config 94 | ch.Unlock() 95 | return nil 96 | } 97 | 98 | func (m AuthModule) ConfigureTarget(target string) (DSN, error) { 99 | dsn, err := dsnFromString(target) 100 | if err != nil { 101 | return DSN{}, err 102 | } 103 | 104 | // Set the credentials from the authentication module 105 | // TODO(@sysadmind): What should the order of precedence be? 106 | if m.Type == "userpass" { 107 | if m.UserPass.Username != "" { 108 | dsn.username = m.UserPass.Username 109 | } 110 | if m.UserPass.Password != "" { 111 | dsn.password = m.UserPass.Password 112 | } 113 | } 114 | 115 | for k, v := range m.Options { 116 | dsn.query.Set(k, v) 117 | } 118 | 119 | return dsn, nil 120 | } 121 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package config 15 | 16 | import ( 17 | "testing" 18 | ) 19 | 20 | func TestLoadConfig(t *testing.T) { 21 | ch := &Handler{ 22 | Config: &Config{}, 23 | } 24 | 25 | err := ch.ReloadConfig("testdata/config-good.yaml", nil) 26 | if err != nil { 27 | t.Errorf("error loading config: %s", err) 28 | } 29 | } 30 | 31 | func TestLoadBadConfigs(t *testing.T) { 32 | ch := &Handler{ 33 | Config: &Config{}, 34 | } 35 | 36 | tests := []struct { 37 | input string 38 | want string 39 | }{ 40 | { 41 | input: "testdata/config-bad-auth-module.yaml", 42 | want: "error parsing config file \"testdata/config-bad-auth-module.yaml\": yaml: unmarshal errors:\n line 3: field pretendauth not found in type config.AuthModule", 43 | }, 44 | { 45 | input: "testdata/config-bad-extra-field.yaml", 46 | want: "error parsing config file \"testdata/config-bad-extra-field.yaml\": yaml: unmarshal errors:\n line 8: field doesNotExist not found in type config.AuthModule", 47 | }, 48 | } 49 | 50 | for _, test := range tests { 51 | t.Run(test.input, func(t *testing.T) { 52 | got := ch.ReloadConfig(test.input, nil) 53 | if got == nil || got.Error() != test.want { 54 | t.Fatalf("ReloadConfig(%q) = %v, want %s", test.input, got, test.want) 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /config/dsn.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package config 15 | 16 | import ( 17 | "fmt" 18 | "net/url" 19 | "regexp" 20 | "strings" 21 | "unicode" 22 | ) 23 | 24 | // DSN represents a parsed datasource. It contains fields for the individual connection components. 25 | type DSN struct { 26 | scheme string 27 | username string 28 | password string 29 | host string 30 | path string 31 | query url.Values 32 | } 33 | 34 | // String makes a dsn safe to print by excluding any passwords. This allows dsn to be used in 35 | // strings and log messages without needing to call a redaction function first. 36 | func (d DSN) String() string { 37 | if d.password != "" { 38 | return fmt.Sprintf("%s://%s:******@%s%s?%s", d.scheme, d.username, d.host, d.path, d.query.Encode()) 39 | } 40 | 41 | if d.username != "" { 42 | return fmt.Sprintf("%s://%s@%s%s?%s", d.scheme, d.username, d.host, d.path, d.query.Encode()) 43 | } 44 | 45 | return fmt.Sprintf("%s://%s%s?%s", d.scheme, d.host, d.path, d.query.Encode()) 46 | } 47 | 48 | // GetConnectionString returns the URL to pass to the driver for database connections. This value should not be logged. 49 | func (d DSN) GetConnectionString() string { 50 | u := url.URL{ 51 | Scheme: d.scheme, 52 | Host: d.host, 53 | Path: d.path, 54 | RawQuery: d.query.Encode(), 55 | } 56 | 57 | // Username and Password 58 | if d.username != "" { 59 | u.User = url.UserPassword(d.username, d.password) 60 | } 61 | 62 | return u.String() 63 | } 64 | 65 | // dsnFromString parses a connection string into a dsn. It will attempt to parse the string as 66 | // a URL and as a set of key=value pairs. If both attempts fail, dsnFromString will return an error. 67 | func dsnFromString(in string) (DSN, error) { 68 | if strings.HasPrefix(in, "postgresql://") || strings.HasPrefix(in, "postgres://") { 69 | return dsnFromURL(in) 70 | } 71 | 72 | // Try to parse as key=value pairs 73 | d, err := dsnFromKeyValue(in) 74 | if err == nil { 75 | return d, nil 76 | } 77 | 78 | // Parse the string as a URL, with the scheme prefixed 79 | d, err = dsnFromURL(fmt.Sprintf("postgresql://%s", in)) 80 | if err == nil { 81 | return d, nil 82 | } 83 | 84 | return DSN{}, fmt.Errorf("could not understand DSN") 85 | } 86 | 87 | // dsnFromURL parses the input as a URL and returns the dsn representation. 88 | func dsnFromURL(in string) (DSN, error) { 89 | u, err := url.Parse(in) 90 | if err != nil { 91 | return DSN{}, err 92 | } 93 | pass, _ := u.User.Password() 94 | user := u.User.Username() 95 | 96 | query := u.Query() 97 | 98 | if queryPass := query.Get("password"); queryPass != "" { 99 | if pass == "" { 100 | pass = queryPass 101 | } 102 | } 103 | query.Del("password") 104 | 105 | if queryUser := query.Get("user"); queryUser != "" { 106 | if user == "" { 107 | user = queryUser 108 | } 109 | } 110 | query.Del("user") 111 | 112 | d := DSN{ 113 | scheme: u.Scheme, 114 | username: user, 115 | password: pass, 116 | host: u.Host, 117 | path: u.Path, 118 | query: query, 119 | } 120 | 121 | return d, nil 122 | } 123 | 124 | // dsnFromKeyValue parses the input as a set of key=value pairs and returns the dsn representation. 125 | func dsnFromKeyValue(in string) (DSN, error) { 126 | // Attempt to confirm at least one key=value pair before starting the rune parser 127 | connstringRe := regexp.MustCompile(`^ *[a-zA-Z0-9]+ *= *[^= ]+`) 128 | if !connstringRe.MatchString(in) { 129 | return DSN{}, fmt.Errorf("input is not a key-value DSN") 130 | } 131 | 132 | // Anything other than known fields should be part of the querystring 133 | query := url.Values{} 134 | 135 | pairs, err := parseKeyValue(in) 136 | if err != nil { 137 | return DSN{}, fmt.Errorf("failed to parse key-value DSN: %v", err) 138 | } 139 | 140 | // Build the dsn from the key=value pairs 141 | d := DSN{ 142 | scheme: "postgresql", 143 | } 144 | 145 | hostname := "" 146 | port := "" 147 | 148 | for k, v := range pairs { 149 | switch k { 150 | case "host": 151 | hostname = v 152 | case "port": 153 | port = v 154 | case "user": 155 | d.username = v 156 | case "password": 157 | d.password = v 158 | default: 159 | query.Set(k, v) 160 | } 161 | } 162 | 163 | if hostname == "" { 164 | hostname = "localhost" 165 | } 166 | 167 | if port == "" { 168 | d.host = hostname 169 | } else { 170 | d.host = fmt.Sprintf("%s:%s", hostname, port) 171 | } 172 | 173 | d.query = query 174 | 175 | return d, nil 176 | } 177 | 178 | // parseKeyValue is a key=value parser. It loops over each rune to split out keys and values 179 | // and attempting to honor quoted values. parseKeyValue will return an error if it is unable 180 | // to properly parse the input. 181 | func parseKeyValue(in string) (map[string]string, error) { 182 | out := map[string]string{} 183 | 184 | inPart := false 185 | inQuote := false 186 | part := []rune{} 187 | key := "" 188 | for _, c := range in { 189 | switch { 190 | case unicode.In(c, unicode.Quotation_Mark): 191 | if inQuote { 192 | inQuote = false 193 | } else { 194 | inQuote = true 195 | } 196 | case unicode.In(c, unicode.White_Space): 197 | if inPart { 198 | if inQuote { 199 | part = append(part, c) 200 | } else { 201 | // Are we finishing a key=value? 202 | if key == "" { 203 | return out, fmt.Errorf("invalid input") 204 | } 205 | out[key] = string(part) 206 | inPart = false 207 | part = []rune{} 208 | } 209 | } else { 210 | // Are we finishing a key=value? 211 | if key == "" { 212 | return out, fmt.Errorf("invalid input") 213 | } 214 | out[key] = string(part) 215 | inPart = false 216 | part = []rune{} 217 | // Do something with the value 218 | } 219 | case c == '=': 220 | if inPart { 221 | inPart = false 222 | key = string(part) 223 | part = []rune{} 224 | } else { 225 | return out, fmt.Errorf("invalid input") 226 | } 227 | default: 228 | inPart = true 229 | part = append(part, c) 230 | } 231 | } 232 | 233 | if key != "" && len(part) > 0 { 234 | out[key] = string(part) 235 | } 236 | 237 | return out, nil 238 | } 239 | -------------------------------------------------------------------------------- /config/dsn_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Prometheus Authors 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // 6 | // http://www.apache.org/licenses/LICENSE-2.0 7 | // 8 | // Unless required by applicable law or agreed to in writing, software 9 | // distributed under the License is distributed on an "AS IS" BASIS, 10 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | // See the License for the specific language governing permissions and 12 | // limitations under the License. 13 | 14 | package config 15 | 16 | import ( 17 | "net/url" 18 | "reflect" 19 | "testing" 20 | ) 21 | 22 | // Test_dsn_String is designed to test different dsn combinations for their string representation. 23 | // dsn.String() is designed to be safe to print, redacting any password information and these test 24 | // cases are intended to cover known cases. 25 | func Test_dsn_String(t *testing.T) { 26 | type fields struct { 27 | scheme string 28 | username string 29 | password string 30 | host string 31 | path string 32 | query url.Values 33 | } 34 | tests := []struct { 35 | name string 36 | fields fields 37 | want string 38 | }{ 39 | { 40 | name: "Without Password", 41 | fields: fields{ 42 | scheme: "postgresql", 43 | username: "test", 44 | host: "localhost:5432", 45 | query: url.Values{}, 46 | }, 47 | want: "postgresql://test@localhost:5432?", 48 | }, 49 | { 50 | name: "With Password", 51 | fields: fields{ 52 | scheme: "postgresql", 53 | username: "test", 54 | password: "supersecret", 55 | host: "localhost:5432", 56 | query: url.Values{}, 57 | }, 58 | want: "postgresql://test:******@localhost:5432?", 59 | }, 60 | { 61 | name: "With Password and Query String", 62 | fields: fields{ 63 | scheme: "postgresql", 64 | username: "test", 65 | password: "supersecret", 66 | host: "localhost:5432", 67 | query: url.Values{ 68 | "ssldisable": []string{"true"}, 69 | }, 70 | }, 71 | want: "postgresql://test:******@localhost:5432?ssldisable=true", 72 | }, 73 | { 74 | name: "With Password, Path, and Query String", 75 | fields: fields{ 76 | scheme: "postgresql", 77 | username: "test", 78 | password: "supersecret", 79 | host: "localhost:5432", 80 | path: "/somevalue", 81 | query: url.Values{ 82 | "ssldisable": []string{"true"}, 83 | }, 84 | }, 85 | want: "postgresql://test:******@localhost:5432/somevalue?ssldisable=true", 86 | }, 87 | } 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | d := DSN{ 91 | scheme: tt.fields.scheme, 92 | username: tt.fields.username, 93 | password: tt.fields.password, 94 | host: tt.fields.host, 95 | path: tt.fields.path, 96 | query: tt.fields.query, 97 | } 98 | if got := d.String(); got != tt.want { 99 | t.Errorf("dsn.String() = %v, want %v", got, tt.want) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | // Test_dsnFromString tests the dsnFromString function with known variations 106 | // of connection string inputs to ensure that it properly parses the input into 107 | // a dsn. 108 | func Test_dsnFromString(t *testing.T) { 109 | 110 | tests := []struct { 111 | name string 112 | input string 113 | want DSN 114 | wantErr bool 115 | }{ 116 | { 117 | name: "Key value with password", 118 | input: "host=host.example.com user=postgres port=5432 password=s3cr3t", 119 | want: DSN{ 120 | scheme: "postgresql", 121 | host: "host.example.com:5432", 122 | username: "postgres", 123 | password: "s3cr3t", 124 | query: url.Values{}, 125 | }, 126 | wantErr: false, 127 | }, 128 | { 129 | name: "Key value with quoted password and space", 130 | input: "host=host.example.com user=postgres port=5432 password=\"s3cr 3t\"", 131 | want: DSN{ 132 | scheme: "postgresql", 133 | host: "host.example.com:5432", 134 | username: "postgres", 135 | password: "s3cr 3t", 136 | query: url.Values{}, 137 | }, 138 | wantErr: false, 139 | }, 140 | { 141 | name: "Key value with different order", 142 | input: "password=abcde host=host.example.com user=postgres port=5432", 143 | want: DSN{ 144 | scheme: "postgresql", 145 | host: "host.example.com:5432", 146 | username: "postgres", 147 | password: "abcde", 148 | query: url.Values{}, 149 | }, 150 | wantErr: false, 151 | }, 152 | { 153 | name: "Key value with different order, quoted password, duplicate password", 154 | input: "password=abcde host=host.example.com user=postgres port=5432 password=\"s3cr 3t\"", 155 | want: DSN{ 156 | scheme: "postgresql", 157 | host: "host.example.com:5432", 158 | username: "postgres", 159 | password: "s3cr 3t", 160 | query: url.Values{}, 161 | }, 162 | wantErr: false, 163 | }, 164 | { 165 | name: "URL with user in query string", 166 | input: "postgresql://host.example.com:5432/tsdb?user=postgres", 167 | want: DSN{ 168 | scheme: "postgresql", 169 | host: "host.example.com:5432", 170 | path: "/tsdb", 171 | query: url.Values{}, 172 | username: "postgres", 173 | }, 174 | wantErr: false, 175 | }, 176 | { 177 | name: "URL with user and password", 178 | input: "postgresql://user:s3cret@host.example.com:5432/tsdb?user=postgres", 179 | want: DSN{ 180 | scheme: "postgresql", 181 | host: "host.example.com:5432", 182 | path: "/tsdb", 183 | query: url.Values{}, 184 | username: "user", 185 | password: "s3cret", 186 | }, 187 | wantErr: false, 188 | }, 189 | { 190 | name: "Alternative URL prefix", 191 | input: "postgres://user:s3cret@host.example.com:5432/tsdb?user=postgres", 192 | want: DSN{ 193 | scheme: "postgres", 194 | host: "host.example.com:5432", 195 | path: "/tsdb", 196 | query: url.Values{}, 197 | username: "user", 198 | password: "s3cret", 199 | }, 200 | wantErr: false, 201 | }, 202 | { 203 | name: "URL with user and password in query string", 204 | input: "postgresql://host.example.com:5432/tsdb?user=postgres&password=s3cr3t", 205 | want: DSN{ 206 | scheme: "postgresql", 207 | host: "host.example.com:5432", 208 | path: "/tsdb", 209 | query: url.Values{}, 210 | username: "postgres", 211 | password: "s3cr3t", 212 | }, 213 | wantErr: false, 214 | }, 215 | } 216 | for _, tt := range tests { 217 | t.Run(tt.name, func(t *testing.T) { 218 | got, err := dsnFromString(tt.input) 219 | if (err != nil) != tt.wantErr { 220 | t.Errorf("dsnFromString() error = %v, wantErr %v", err, tt.wantErr) 221 | return 222 | } 223 | if !reflect.DeepEqual(got, tt.want) { 224 | t.Errorf("dsnFromString() = %+v, want %+v", got, tt.want) 225 | } 226 | }) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /config/testdata/config-bad-auth-module.yaml: -------------------------------------------------------------------------------- 1 | auth_modules: 2 | foo: 3 | pretendauth: 4 | username: test 5 | password: pass 6 | options: 7 | extra: "1" 8 | -------------------------------------------------------------------------------- /config/testdata/config-bad-extra-field.yaml: -------------------------------------------------------------------------------- 1 | auth_modules: 2 | foo: 3 | userpass: 4 | username: test 5 | password: pass 6 | options: 7 | extra: "1" 8 | doesNotExist: test 9 | -------------------------------------------------------------------------------- /config/testdata/config-good.yaml: -------------------------------------------------------------------------------- 1 | auth_modules: 2 | first: 3 | type: userpass 4 | userpass: 5 | username: first 6 | password: firstpass 7 | options: 8 | sslmode: disable 9 | -------------------------------------------------------------------------------- /gh-assets-clone.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to setup the assets clone of the repository using GIT_ASSETS_BRANCH and 3 | # GIT_API_KEY. 4 | 5 | [ ! -z "$GIT_ASSETS_BRANCH" ] || exit 1 6 | 7 | setup_git() { 8 | git config --global user.email "travis@travis-ci.org" || exit 1 9 | git config --global user.name "Travis CI" || exit 1 10 | } 11 | 12 | # Constants 13 | ASSETS_DIR=".assets-branch" 14 | 15 | # Clone the assets branch with the correct credentials 16 | git clone --single-branch -b "$GIT_ASSETS_BRANCH" \ 17 | "https://${GIT_API_KEY}@github.com/${TRAVIS_REPO_SLUG}.git" "$ASSETS_DIR" || exit 1 18 | 19 | -------------------------------------------------------------------------------- /gh-metrics-push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to copy and push new metric versions to the assets branch. 3 | 4 | [ ! -z "$GIT_ASSETS_BRANCH" ] || exit 1 5 | [ ! -z "$GIT_API_KEY" ] || exit 1 6 | 7 | version=$(git describe HEAD) || exit 1 8 | 9 | # Constants 10 | ASSETS_DIR=".assets-branch" 11 | METRICS_DIR="$ASSETS_DIR/metriclists" 12 | 13 | # Ensure metrics dir exists 14 | mkdir -p "$METRICS_DIR/" 15 | 16 | # Remove old files so we spot deletions 17 | rm -f "$METRICS_DIR/.*.unique" 18 | 19 | # Copy new files 20 | cp -f -t "$METRICS_DIR/" ./.metrics.*.prom.unique || exit 1 21 | 22 | # Enter the assets dir and push. 23 | cd "$ASSETS_DIR" || exit 1 24 | 25 | git add "metriclists" || exit 1 26 | git commit -m "Added unique metrics for build from $version" || exit 1 27 | git push origin "$GIT_ASSETS_BRANCH" || exit 1 28 | 29 | exit 0 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/prometheus-community/postgres_exporter 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/DATA-DOG/go-sqlmock v1.5.2 9 | github.com/alecthomas/kingpin/v2 v2.4.0 10 | github.com/blang/semver/v4 v4.0.0 11 | github.com/lib/pq v1.10.9 12 | github.com/prometheus/client_golang v1.22.0 13 | github.com/prometheus/client_model v0.6.1 14 | github.com/prometheus/common v0.63.0 15 | github.com/prometheus/exporter-toolkit v0.14.0 16 | github.com/smartystreets/goconvey v1.8.1 17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c 18 | gopkg.in/yaml.v2 v2.4.0 19 | gopkg.in/yaml.v3 v3.0.1 20 | ) 21 | 22 | require ( 23 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 27 | github.com/gopherjs/gopherjs v1.17.2 // indirect 28 | github.com/jpillora/backoff v1.0.0 // indirect 29 | github.com/jtolds/gls v4.20.0+incompatible // indirect 30 | github.com/kr/pretty v0.3.1 // indirect 31 | github.com/kr/text v0.2.0 // indirect 32 | github.com/mdlayher/socket v0.4.1 // indirect 33 | github.com/mdlayher/vsock v1.2.1 // indirect 34 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 35 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 36 | github.com/prometheus/procfs v0.15.1 // indirect 37 | github.com/rogpeppe/go-internal v1.10.0 // indirect 38 | github.com/smarty/assertions v1.15.0 // indirect 39 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 40 | golang.org/x/crypto v0.36.0 // indirect 41 | golang.org/x/net v0.38.0 // indirect 42 | golang.org/x/oauth2 v0.25.0 // indirect 43 | golang.org/x/sync v0.12.0 // indirect 44 | golang.org/x/sys v0.31.0 // indirect 45 | golang.org/x/text v0.23.0 // indirect 46 | google.golang.org/protobuf v1.36.5 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /postgres-metrics-get-changes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to parse a text exposition format file into a unique list of metrics 3 | # output by the exporter and then build lists of added/removed metrics. 4 | 5 | old_src="$1" 6 | if [ ! -d "$old_src" ] ; then 7 | mkdir -p "$old_src" 8 | fi 9 | 10 | function generate_add_removed() { 11 | type="$1" 12 | pg_version="$2" 13 | old_version="$3" 14 | new_version="$4" 15 | 16 | if [ ! -e "$old_version" ] ; then 17 | touch "$old_version" 18 | fi 19 | 20 | comm -23 "$old_version" "$new_version" > ".metrics.${type}.${pg_version}.removed" 21 | comm -13 "$old_version" "$new_version" > ".metrics.${type}.${pg_version}.added" 22 | } 23 | 24 | for raw_prom in $(echo .*.prom) ; do 25 | # Get the type and version 26 | type=$(echo "$raw_prom" | cut -d'.' -f3) 27 | pg_version=$(echo "$raw_prom" | cut -d'.' -f4- | sed 's/\.prom$//g') 28 | 29 | unique_file="${raw_prom}.unique" 30 | old_unique_file="$old_src/$unique_file" 31 | 32 | # Strip, sort and deduplicate the label names 33 | grep -v '#' "$raw_prom" | \ 34 | rev | cut -d' ' -f2- | \ 35 | rev | cut -d'{' -f1 | \ 36 | sort | \ 37 | uniq > "$unique_file" 38 | 39 | generate_add_removed "$type" "$pg_version" "$old_unique_file" "$unique_file" 40 | done 41 | -------------------------------------------------------------------------------- /postgres_exporter.rc: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # PROVIDE: postgres_exporter 4 | # REQUIRE: LOGIN 5 | # KEYWORD: shutdown 6 | # 7 | # rc-script for postgres_exporter 8 | # 9 | # 10 | # Add the following lines to /etc/rc.conf.local or /etc/rc.conf 11 | # to enable this service: 12 | # 13 | # postgres_exporter_enable (bool): Set to NO by default. 14 | # Set it to YES to enable postgres_exporter. 15 | # postgres_exporter_user (string): Set user that postgres_exporter will run under 16 | # Default is "nobody". 17 | # postgres_exporter_group (string): Set group that postgres_exporter will run under 18 | # Default is "nobody". 19 | # postgres_exporter_args (string): Set extra arguments to pass to postgres_exporter 20 | # Default is "". 21 | # postgres_exporter_listen_address (string):Set ip:port to listen on for web interface and telemetry. 22 | # Defaults to ":9187" 23 | # postgres_exporter_pg_user (string): Set the Postgres database user 24 | # Defaults to "postgres_exporter" 25 | # postgres_exporter_pg_pass (string): Set the Postgres datase password 26 | # Default is empty 27 | # postgres_exporter_pg_host (string): Set the Postgres database server 28 | # Defaults to "localhost" 29 | # postgres_exporter_pg_port (string): Set the Postgres database port 30 | # Defaults to "5432" 31 | 32 | # Add extra arguments via "postgres_exporter_args" 33 | # (see $ postgres_exporter --help) 34 | 35 | 36 | . /etc/rc.subr 37 | 38 | name=postgres_exporter 39 | rcvar=postgres_exporter_enable 40 | 41 | load_rc_config $name 42 | 43 | : ${postgres_exporter_enable:="NO"} 44 | : ${postgres_exporter_user:="nobody"} 45 | : ${postgres_exporter_group:="nobody"} 46 | : ${postgres_exporter_args:=""} 47 | : ${postgres_exporter_listen_address:=":9187"} 48 | : ${postgres_exporter_pg_user:="postgres_exporter"} 49 | : ${postgres_exporter_pg_pass:=""} 50 | : ${postgres_exporter_pg_host:="localhost"} 51 | : ${postgres_exporter_pg_port:="5432"} 52 | 53 | postgres_exporter_data_source_name="postgresql://${postgres_exporter_pg_user}:${postgres_exporter_pg_pass}@${postgres_exporter_pg_host}:${postgres_exporter_pg_port}/postgres?sslmode=disable" 54 | 55 | 56 | pidfile=/var/run/postgres_exporter.pid 57 | command="/usr/sbin/daemon" 58 | procname="/usr/local/bin/postgres_exporter" 59 | command_args="-f -p ${pidfile} -T ${name} \ 60 | /usr/bin/env DATA_SOURCE_NAME="${postgres_exporter_data_source_name}" ${procname} \ 61 | --web.listen-address=${postgres_exporter_listen_address} \ 62 | ${postgres_exporter_args}" 63 | 64 | start_precmd=postgres_exporter_startprecmd 65 | 66 | postgres_exporter_startprecmd() 67 | { 68 | if [ ! -e ${pidfile} ]; then 69 | install -o ${postgres_exporter_user} -g ${postgres_exporter_group} /dev/null ${pidfile}; 70 | fi 71 | } 72 | 73 | load_rc_config $name 74 | run_rc_command "$1" 75 | -------------------------------------------------------------------------------- /postgres_exporter_integration_test_script: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script wraps the integration test binary so it produces concatenated 3 | # test output. 4 | 5 | test_binary=$1 6 | shift 7 | output_cov=$1 8 | shift 9 | 10 | echo "Test Binary: $test_binary" 1>&2 11 | echo "Coverage File: $output_cov" 1>&2 12 | 13 | echo "mode: count" > $output_cov 14 | 15 | test_cov=$(mktemp) 16 | $test_binary -test.coverprofile=$test_cov $@ || exit 1 17 | tail -n +2 $test_cov >> $output_cov 18 | rm -f $test_cov 19 | -------------------------------------------------------------------------------- /postgres_mixin/.gitignore: -------------------------------------------------------------------------------- 1 | /alerts.yaml 2 | /rules.yaml 3 | dashboards_out 4 | -------------------------------------------------------------------------------- /postgres_mixin/Makefile: -------------------------------------------------------------------------------- 1 | JSONNET_FMT := jsonnetfmt -n 2 --max-blank-lines 2 --string-style s --comment-style s 2 | 3 | default: build 4 | 5 | all: fmt lint build clean 6 | 7 | fmt: 8 | find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ 9 | xargs -n 1 -- $(JSONNET_FMT) -i 10 | 11 | lint: 12 | find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ 13 | while read f; do \ 14 | $(JSONNET_FMT) "$$f" | diff -u "$$f" -; \ 15 | done 16 | 17 | mixtool lint mixin.libsonnet 18 | 19 | build: 20 | mixtool generate all mixin.libsonnet 21 | 22 | clean: 23 | rm -rf dashboards_out alerts.yaml rules.yaml 24 | -------------------------------------------------------------------------------- /postgres_mixin/README.md: -------------------------------------------------------------------------------- 1 | # Postgres Mixin 2 | 3 | _This is a work in progress. We aim for it to become a good role model for alerts 4 | and dashboards eventually, but it is not quite there yet._ 5 | 6 | The Postgres Mixin is a set of configurable, reusable, and extensible alerts and 7 | dashboards based on the metrics exported by the Postgres Exporter. The mixin creates 8 | recording and alerting rules for Prometheus and suitable dashboard descriptions 9 | for Grafana. 10 | 11 | To use them, you need to have `mixtool` and `jsonnetfmt` installed. If you 12 | have a working Go development environment, it's easiest to run the following: 13 | ```bash 14 | $ go get github.com/monitoring-mixins/mixtool/cmd/mixtool 15 | $ go get github.com/google/go-jsonnet/cmd/jsonnetfmt 16 | ``` 17 | 18 | You can then build the Prometheus rules files `alerts.yaml` and 19 | `rules.yaml` and a directory `dashboard_out` with the JSON dashboard files 20 | for Grafana: 21 | ```bash 22 | $ make build 23 | ``` 24 | 25 | For more advanced uses of mixins, see 26 | https://github.com/monitoring-mixins/docs. 27 | -------------------------------------------------------------------------------- /postgres_mixin/alerts/alerts.libsonnet: -------------------------------------------------------------------------------- 1 | (import 'postgres.libsonnet') 2 | -------------------------------------------------------------------------------- /postgres_mixin/alerts/postgres.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | prometheusAlerts+:: { 3 | groups+: [ 4 | { 5 | name: 'PostgreSQL', 6 | rules: [ 7 | { 8 | alert: 'PostgreSQLMaxConnectionsReached', 9 | annotations: { 10 | description: '{{ $labels.instance }} is exceeding the currently configured maximum Postgres connection limit (current value: {{ $value }}s). Services may be degraded - please take immediate action (you probably need to increase max_connections in the Docker image and re-deploy.', 11 | summary: '{{ $labels.instance }} has maxed out Postgres connections.', 12 | }, 13 | expr: ||| 14 | sum by (instance) (pg_stat_activity_count{%(postgresExporterSelector)s}) 15 | >= 16 | sum by (instance) (pg_settings_max_connections{%(postgresExporterSelector)s}) 17 | - 18 | sum by (instance) (pg_settings_superuser_reserved_connections{%(postgresExporterSelector)s}) 19 | ||| % $._config, 20 | 'for': '1m', 21 | labels: { 22 | severity: 'warning', 23 | }, 24 | }, 25 | { 26 | alert: 'PostgreSQLHighConnections', 27 | annotations: { 28 | description: '{{ $labels.instance }} is exceeding 80% of the currently configured maximum Postgres connection limit (current value: {{ $value }}s). Please check utilization graphs and confirm if this is normal service growth, abuse or an otherwise temporary condition or if new resources need to be provisioned (or the limits increased, which is mostly likely).', 29 | summary: '{{ $labels.instance }} is over 80% of max Postgres connections.', 30 | }, 31 | expr: ||| 32 | sum by (instance) (pg_stat_activity_count{%(postgresExporterSelector)s}) 33 | > 34 | ( 35 | sum by (instance) (pg_settings_max_connections{%(postgresExporterSelector)s}) 36 | - 37 | sum by (instance) (pg_settings_superuser_reserved_connections{%(postgresExporterSelector)s}) 38 | ) * 0.8 39 | ||| % $._config, 40 | 'for': '10m', 41 | labels: { 42 | severity: 'warning', 43 | }, 44 | }, 45 | { 46 | alert: 'PostgreSQLDown', 47 | annotations: { 48 | description: '{{ $labels.instance }} is rejecting query requests from the exporter, and thus probably not allowing DNS requests to work either. User services should not be effected provided at least 1 node is still alive.', 49 | summary: 'PostgreSQL is not processing queries: {{ $labels.instance }}', 50 | }, 51 | expr: 'pg_up{%(postgresExporterSelector)s} != 1' % $._config, 52 | 'for': '1m', 53 | labels: { 54 | severity: 'warning', 55 | }, 56 | }, 57 | { 58 | alert: 'PostgreSQLSlowQueries', 59 | annotations: { 60 | description: 'PostgreSQL high number of slow queries {{ $labels.cluster }} for database {{ $labels.datname }} with a value of {{ $value }} ', 61 | summary: 'PostgreSQL high number of slow on {{ $labels.cluster }} for database {{ $labels.datname }} ', 62 | }, 63 | expr: ||| 64 | avg by (datname) ( 65 | rate ( 66 | pg_stat_activity_max_tx_duration{datname!~"template.*",%(postgresExporterSelector)s}[2m] 67 | ) 68 | ) > 2 * 60 69 | ||| % $._config, 70 | 'for': '2m', 71 | labels: { 72 | severity: 'warning', 73 | }, 74 | }, 75 | { 76 | alert: 'PostgreSQLQPS', 77 | annotations: { 78 | description: 'PostgreSQL high number of queries per second on {{ $labels.cluster }} for database {{ $labels.datname }} with a value of {{ $value }}', 79 | summary: 'PostgreSQL high number of queries per second {{ $labels.cluster }} for database {{ $labels.datname }}', 80 | }, 81 | expr: ||| 82 | avg by (datname) ( 83 | irate( 84 | pg_stat_database_xact_commit{datname!~"template.*",%(postgresExporterSelector)s}[5m] 85 | ) 86 | + 87 | irate( 88 | pg_stat_database_xact_rollback{datname!~"template.*",%(postgresExporterSelector)s}[5m] 89 | ) 90 | ) > 10000 91 | ||| % $._config, 92 | 'for': '5m', 93 | labels: { 94 | severity: 'warning', 95 | }, 96 | }, 97 | { 98 | alert: 'PostgreSQLCacheHitRatio', 99 | annotations: { 100 | description: 'PostgreSQL low on cache hit rate on {{ $labels.cluster }} for database {{ $labels.datname }} with a value of {{ $value }}', 101 | summary: 'PostgreSQL low cache hit rate on {{ $labels.cluster }} for database {{ $labels.datname }}', 102 | }, 103 | expr: ||| 104 | avg by (datname) ( 105 | rate(pg_stat_database_blks_hit{datname!~"template.*",%(postgresExporterSelector)s}[5m]) 106 | / 107 | ( 108 | rate( 109 | pg_stat_database_blks_hit{datname!~"template.*",%(postgresExporterSelector)s}[5m] 110 | ) 111 | + 112 | rate( 113 | pg_stat_database_blks_read{datname!~"template.*",%(postgresExporterSelector)s}[5m] 114 | ) 115 | ) 116 | ) < 0.98 117 | ||| % $._config, 118 | 'for': '5m', 119 | labels: { 120 | severity: 'warning', 121 | }, 122 | }, 123 | ], 124 | }, 125 | ], 126 | }, 127 | } 128 | -------------------------------------------------------------------------------- /postgres_mixin/config.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | _config+:: { 3 | postgresExporterSelector: '', 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /postgres_mixin/dashboards/dashboards.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | grafanaDashboards+:: { 3 | 'postgres-overview.json': (import 'postgres-overview.json'), 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /postgres_mixin/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wrouesnel/postgres_exporter/postgres_mixin 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /postgres_mixin/mixin.libsonnet: -------------------------------------------------------------------------------- 1 | (import 'alerts/alerts.libsonnet') + 2 | (import 'dashboards/dashboards.libsonnet') + 3 | (import 'config.libsonnet') 4 | -------------------------------------------------------------------------------- /queries.yaml: -------------------------------------------------------------------------------- 1 | # Adding queries to this file is deprecated 2 | # Example queries have been transformed into collectors. --------------------------------------------------------------------------------