├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── question.md ├── dependabot.yml ├── renovate.json └── workflows │ ├── codeql-analysis.yml │ ├── depsreview.yaml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .golangci.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── contrib ├── collect_lists_length_growing.lua ├── grafana_prometheus_redis_dashboard.json ├── grafana_prometheus_redis_dashboard_exporter_version_0.3x.json ├── k8s-redis-and-exporter-deployment.yaml ├── manifest.yml ├── openshift-template.yaml ├── redis-mixin │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── alerts │ │ └── redis.libsonnet │ ├── config.libsonnet │ ├── dashboards │ │ ├── redis-overview.json │ │ └── redis.libsonnet │ ├── mixin.libsonnet │ └── rules │ │ └── redis.libsonnet ├── sample-pwd-file.json ├── sample-pwd-file.json-malformed ├── sample_collect_script.lua └── tls │ └── gen-test-certs.sh ├── docker-compose.yml ├── exporter ├── clients.go ├── clients_test.go ├── exporter.go ├── exporter_test.go ├── http.go ├── http_test.go ├── info.go ├── info_test.go ├── key_groups.go ├── key_groups_test.go ├── keys.go ├── keys_test.go ├── latency.go ├── latency_test.go ├── lua.go ├── lua_test.go ├── metrics.go ├── metrics_test.go ├── modules.go ├── modules_test.go ├── nodes.go ├── nodes_test.go ├── pwd_file.go ├── pwd_file_test.go ├── redis.go ├── redis_test.go ├── sentinels.go ├── sentinels_test.go ├── slowlog.go ├── slowlog_test.go ├── streams.go ├── streams_test.go ├── tile38.go ├── tile38_test.go ├── tls.go └── tls_test.go ├── go.mod ├── go.sum ├── main.go └── package-github-binaries.sh /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: File a bug report 4 | title: '' 5 | labels: bug 6 | assignees: oliver006 7 | 8 | --- 9 | 10 | **Describe the problem** 11 | A clear and concise description of what the bug is. 12 | 13 | **What version of redis_exporter are you running?** 14 | Please run `redis_exporter --version` if you're not sure what version you're running. 15 | [ ] 0.3x.x 16 | [ ] 1.x.x 17 | 18 | 19 | **Running the exporter** 20 | What's the full command you're using to run the exporter? (please remove passwords and other sensitive data) 21 | 22 | 23 | **Expected behavior** 24 | What metrics are missing? What metrics are wrong? Is something missing that was present in an earlier version? 25 | Did you upgrade from 0.3x.x to 1.0 and are scraping multiple hosts? [Have a look here ](https://github.com/oliver006/redis_exporter#prometheus-configuration-to-scrape-multiple-redis-hosts) how the configuration changed. 26 | 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question 4 | title: '' 5 | labels: question 6 | assignees: oliver006 7 | 8 | --- 9 | 10 | **Describe the problem** 11 | A clear and concise description of what the question is. 12 | 13 | **What version of redis_exporter are you running?** 14 | Please run `redis_exporter --version` if you're not sure what version you're running. 15 | [ ] 0.3x.x 16 | [ ] 1.x.x 17 | 18 | 19 | **Running the exporter** 20 | What's the full command you're using to run the exporter? (please remove passwords and other sensitive data) 21 | Please include details about env variables, command line parameters, your orchestration setup, etc. 22 | 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your question. 26 | 27 | 28 | **Additional context** 29 | Add any other context about the question here. 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": [ 3 | ".drone.yml" 4 | ], 5 | "packageRules": [ 6 | { 7 | "matchPackageNames": ["redis"], 8 | "enabled": false 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | schedule: 8 | - cron: '0 15 * * 5' 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | CodeQL-Build: 15 | 16 | permissions: 17 | actions: read # for github/codeql-action/init to get workflow details 18 | contents: read # for actions/checkout to fetch code 19 | security-events: write # for github/codeql-action/autobuild to send a status report 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | with: 26 | # We must fetch at least the immediate parents so that if this is 27 | # a pull request then we can checkout the head. 28 | fetch-depth: 2 29 | 30 | # If this run was triggered by a pull request event, then checkout 31 | # the head of the pull request instead of the merge commit. 32 | - run: git checkout HEAD^2 33 | if: ${{ github.event_name == 'pull_request' }} 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v3 38 | # Override language selection by uncommenting this and choosing your languages 39 | # with: 40 | # languages: go, javascript, csharp, python, cpp, java 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v3 46 | 47 | # ℹ️ Command-line programs to run using the OS shell. 48 | # 📚 https://git.io/JvXDl 49 | 50 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 51 | # and modify them (or add more) to build your code if your project 52 | # uses a compiled language 53 | 54 | #- run: | 55 | # make bootstrap 56 | # make release 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v3 60 | -------------------------------------------------------------------------------- /.github/workflows/depsreview.yaml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 'Checkout Repository' 12 | uses: actions/checkout@v4 13 | - name: 'Dependency Review' 14 | uses: actions/dependency-review-action@v4 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release-binaries: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: '1.24' 20 | 21 | - name: Build binaries 22 | run: | 23 | make build-all-binaries 24 | ls -la 25 | ls -la .build/ 26 | ./package-github-binaries.sh 27 | ls -la dist/ 28 | 29 | - name: Add binaries to release 30 | uses: ncipollo/release-action@v1 31 | with: 32 | artifacts: "dist/*" 33 | allowUpdates: true 34 | omitBodyDuringUpdate: true 35 | 36 | 37 | build-and-push-docker-images: 38 | runs-on: ubuntu-latest 39 | permissions: 40 | contents: read 41 | packages: write 42 | attestations: write 43 | id-token: write 44 | 45 | steps: 46 | - name: Checkout code 47 | uses: actions/checkout@v4 48 | 49 | - name: Set up QEMU 50 | uses: docker/setup-qemu-action@v3 51 | 52 | - name: Set up Docker Buildx 53 | uses: docker/setup-buildx-action@v3 54 | 55 | - name: Login to Docker Hub 56 | uses: docker/login-action@v3 57 | with: 58 | username: ${{ secrets.DOCKERHUB_USERNAME }} 59 | password: ${{ secrets.DOCKERHUB_TOKEN }} 60 | 61 | - name: Login to ghcr.io 62 | uses: docker/login-action@v3 63 | with: 64 | registry: ghcr.io 65 | username: ${{ github.repository_owner }} 66 | password: ${{ secrets.GITHUB_TOKEN }} 67 | 68 | - name: Login to quay.io 69 | uses: docker/login-action@v3 70 | with: 71 | registry: quay.io 72 | username: ${{ secrets.QUAY_USERNAME }} 73 | password: ${{ secrets.QUAY_TOKEN }} 74 | 75 | - name: Docker meta 76 | id: meta 77 | uses: docker/metadata-action@v5 78 | with: 79 | # list of Docker images to use as base name for tags 80 | images: | 81 | oliver006/redis_exporter 82 | ghcr.io/oliver006/redis_exporter 83 | quay.io/oliver006/redis_exporter 84 | 85 | - name: Build and push scratch image 86 | uses: docker/build-push-action@v6 87 | with: 88 | context: . 89 | target: scratch-release 90 | platforms: linux/amd64,linux/arm,linux/arm64 91 | push: true 92 | tags: ${{ steps.meta.outputs.tags }} 93 | labels: ${{ steps.meta.outputs.labels }} 94 | build-args: | 95 | TAG=${{ github.ref_name }} 96 | SHA1=${{ github.sha }} 97 | 98 | - name: Build and push alpine image 99 | uses: docker/build-push-action@v6 100 | with: 101 | context: . 102 | target: alpine 103 | platforms: linux/amd64,linux/arm,linux/arm64 104 | push: true 105 | tags: oliver006/redis_exporter:${{ github.ref_name }}-alpine,ghcr.io/oliver006/redis_exporter:${{ github.ref_name }}-alpine,quay.io/oliver006/redis_exporter:${{ github.ref_name }}-alpine,oliver006/redis_exporter:alpine,ghcr.io/oliver006/redis_exporter:alpine,quay.io/oliver006/redis_exporter:alpine 106 | labels: ${{ steps.meta.outputs.labels }} 107 | build-args: | 108 | TAG=${{ github.ref_name }} 109 | SHA1=${{ github.sha }} 110 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | - "v*" 9 | 10 | jobs: 11 | test-stuff: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Docker 18 | uses: docker/setup-buildx-action@v3 19 | 20 | - name: Set up Docker Compose 21 | run: sudo apt-get install docker-compose 22 | 23 | # need to do this before we start the services as they need the TLS creds 24 | - name: Create test certs for TLS 25 | run: | 26 | make test-certs 27 | chmod 777 ./contrib/tls/* 28 | 29 | - name: Start services 30 | run: docker-compose up -d 31 | working-directory: ./ 32 | 33 | - name: Setup Go 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version: '1.24' 37 | 38 | - name: Install Dependencies 39 | run: go mod tidy 40 | 41 | - name: Docker logs 42 | run: | 43 | echo "${{ toJson(job) }}" 44 | docker ps -a 45 | echo "ok" 46 | 47 | - name: Run tests 48 | env: 49 | LOG_LEVEL: "info" 50 | run: | 51 | sleep 15 52 | make test 53 | 54 | 55 | - name: Run tests - valkey 8 56 | env: 57 | LOG_LEVEL: "info" 58 | TEST_REDIS_URI: "redis://localhost:16382" 59 | TEST_VALKEY8_TLS_URI: "valkeys://localhost:16386" 60 | TEST_PWD_REDIS_URI: "redis://:redis-password@localhost:16380" 61 | run: | 62 | go test -v -race -p 1 ./... 63 | 64 | 65 | - name: Upload coverage to Codecov 66 | uses: codecov/codecov-action@v5 67 | with: 68 | fail_ci_if_error: true 69 | files: ./coverage.txt 70 | token: ${{ secrets.CODECOV_TOKEN }} # required 71 | verbose: true 72 | 73 | - name: Upload coverage to Coveralls 74 | uses: coverallsapp/github-action@v2 75 | with: 76 | file: coverage.txt 77 | 78 | - name: Stop services 79 | run: docker-compose down 80 | working-directory: ./ 81 | 82 | 83 | lint-stuff: 84 | runs-on: ubuntu-latest 85 | 86 | steps: 87 | - name: Checkout code 88 | uses: actions/checkout@v4 89 | 90 | - name: Setup Go 91 | uses: actions/setup-go@v5 92 | with: 93 | go-version: '1.24' 94 | 95 | - name: Install Dependencies 96 | run: go mod tidy 97 | 98 | - name: golangci-lint 99 | uses: golangci/golangci-lint-action@v8 100 | with: 101 | version: v2.1.5 102 | args: "--tests=false" 103 | 104 | - name: Run checks 105 | env: 106 | LOG_LEVEL: "info" 107 | run: | 108 | make checks 109 | 110 | 111 | build-stuff: 112 | runs-on: ubuntu-latest 113 | steps: 114 | - name: Checkout code 115 | uses: actions/checkout@v4 116 | 117 | - name: Setup Go 118 | uses: actions/setup-go@v5 119 | with: 120 | go-version: '1.24' 121 | 122 | - name: Install Dependencies 123 | run: go mod tidy 124 | 125 | - name: Build some binaries 126 | run: make build-some-amd64-binaries 127 | 128 | - name: Generate mixin 129 | run: make mixin 130 | 131 | - name: Set up Docker Buildx 132 | uses: docker/setup-buildx-action@v3 133 | 134 | - name: Test Docker Image Build - Alpine 135 | uses: docker/build-push-action@v6 136 | with: 137 | push: false 138 | target: alpine 139 | tags: user/app:tst 140 | file: Dockerfile 141 | build-args: "GOARCH=amd64" 142 | 143 | - name: Test Docker Image Build - Scratch 144 | uses: docker/build-push-action@v6 145 | with: 146 | push: false 147 | target: scratch-release 148 | tags: user/app:tst 149 | file: Dockerfile 150 | build-args: "GOARCH=amd64" 151 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | redis_exporter 2 | coverage.out 3 | coverage.txt 4 | dist/ 5 | pkg/ 6 | src/ 7 | .build/ 8 | .DS_Store 9 | .idea 10 | .vscode/ 11 | *.rdb 12 | contrib/tls/ca.crt 13 | contrib/tls/ca.key 14 | contrib/tls/ca.txt 15 | contrib/tls/redis.crt 16 | contrib/tls/redis.key 17 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | exclusions: 4 | presets: 5 | - comments 6 | - std-error-handling 7 | - common-false-positives 8 | - legacy 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG TARGETPLATFORM 2 | 3 | # 4 | # build container 5 | # 6 | FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder 7 | WORKDIR /go/src/github.com/oliver006/redis_exporter/ 8 | 9 | ADD . /go/src/github.com/oliver006/redis_exporter/ 10 | 11 | ARG SHA1="[no-sha]" 12 | ARG TAG="[no-tag]" 13 | ARG TARGETOS 14 | ARG TARGETARCH 15 | 16 | #RUN printf "nameserver 1.1.1.1\nnameserver 8.8.8.8"> /etc/resolv.conf \ && apk --no-cache add ca-certificates git 17 | 18 | RUN apk --no-cache add ca-certificates git 19 | RUN BUILD_DATE=$(date +%F-%T) CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /redis_exporter \ 20 | -ldflags "-s -w -extldflags \"-static\" -X main.BuildVersion=$TAG -X main.BuildCommitSha=$SHA1 -X main.BuildDate=$BUILD_DATE" . 21 | 22 | RUN [ "$TARGETARCH" = "amd64" ] && /redis_exporter -version || ls -la /redis_exporter 23 | 24 | # 25 | # scratch release container 26 | # 27 | FROM scratch AS scratch-release 28 | 29 | COPY --from=builder /redis_exporter /redis_exporter 30 | COPY --from=builder /etc/ssl/certs /etc/ssl/certs 31 | COPY --from=builder /etc/nsswitch.conf /etc/nsswitch.conf 32 | 33 | # Run as non-root user for secure environments 34 | USER 59000:59000 35 | 36 | EXPOSE 9121 37 | ENTRYPOINT [ "/redis_exporter" ] 38 | 39 | 40 | # 41 | # Alpine release container 42 | # 43 | FROM alpine:3.21 AS alpine 44 | 45 | COPY --from=builder /redis_exporter /redis_exporter 46 | COPY --from=builder /etc/ssl/certs /etc/ssl/certs 47 | 48 | # Run as non-root user for secure environments 49 | USER 59000:59000 50 | 51 | EXPOSE 9121 52 | ENTRYPOINT [ "/redis_exporter" ] 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Oliver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | DOCKER_COMPOSE := $(if $(shell which docker-compose),docker-compose,docker compose) 4 | 5 | .PHONY: build 6 | build: 7 | go build . 8 | 9 | 10 | .PHONY: docker-all 11 | docker-all: docker-env-up docker-test docker-env-down 12 | 13 | 14 | .PHONY: docker-env-up 15 | docker-env-up: 16 | $(DOCKER_COMPOSE) -f docker-compose.yml up -d 17 | 18 | 19 | .PHONY: docker-env-down 20 | docker-env-down: 21 | $(DOCKER_COMPOSE) -f docker-compose.yml down 22 | 23 | 24 | .PHONY: docker-test 25 | docker-test: 26 | $(DOCKER_COMPOSE) -f docker-compose.yml up -d 27 | $(DOCKER_COMPOSE) -f docker-compose.yml run --rm tests bash -c 'make test' 28 | 29 | 30 | .PHONY: test-certs 31 | test-certs: 32 | contrib/tls/gen-test-certs.sh 33 | 34 | 35 | .PHONY: test 36 | test: 37 | TEST_VALKEY7_URI="valkey://localhost:16384" \ 38 | TEST_VALKEY8_URI="valkey://localhost:16382" \ 39 | TEST_VALKEY8_TLS_URI="valkeys://localhost:16386" \ 40 | TEST_REDIS7_TLS_URI="rediss://localhost:16387" \ 41 | TEST_REDIS_URI="redis://localhost:16385" \ 42 | TEST_REDIS8_URI="redis://localhost:16388" \ 43 | TEST_REDIS7_URI="redis://localhost:16385" \ 44 | TEST_REDIS5_URI="redis://localhost:16383" \ 45 | TEST_REDIS6_URI="redis://localhost:16379" \ 46 | TEST_REDIS_2_8_URI="redis://localhost:16381" \ 47 | TEST_KEYDB01_URI="redis://localhost:16401" \ 48 | TEST_KEYDB02_URI="redis://localhost:16402" \ 49 | TEST_PWD_REDIS_URI="redis://:redis-password@localhost:16380" \ 50 | TEST_USER_PWD_REDIS_URI="redis://exporter:exporter-password@localhost:16390" \ 51 | TEST_REDIS_CLUSTER_MASTER_URI="redis://localhost:17000" \ 52 | TEST_REDIS_CLUSTER_SLAVE_URI="redis://localhost:17005" \ 53 | TEST_REDIS_CLUSTER_PASSWORD_URI="redis://localhost:17006" \ 54 | TEST_TILE38_URI="redis://localhost:19851" \ 55 | TEST_REDIS_SENTINEL_URI="redis://localhost:26379" \ 56 | TEST_REDIS_MODULES_URI="redis://localhost:36379" \ 57 | go test -v -covermode=atomic -cover -race -coverprofile=coverage.txt -p 1 ./... 58 | 59 | .PHONY: lint 60 | lint: 61 | # 62 | # this will run the default linters on non-test files 63 | # and then all but the "errcheck" linters on the tests 64 | golangci-lint run --tests=false --exclude-use-default 65 | golangci-lint run -D=errcheck --exclude-use-default 66 | 67 | .PHONY: checks 68 | checks: 69 | go vet ./... 70 | echo "checking gofmt" 71 | @if [ "$(shell gofmt -e -l . | wc -l)" -ne 0 ]; then exit 1; fi 72 | echo "checking gofmt - DONE" 73 | 74 | .PHONY: mixin 75 | mixin: 76 | cd contrib/redis-mixin && \ 77 | $(MAKE) all && \ 78 | cd ../../ 79 | 80 | 81 | BUILD_DT:=$(shell date +%F-%T) 82 | GO_LDFLAGS:="-s -w -extldflags \"-static\" -X main.BuildVersion=${GITHUB_REF_NAME} -X main.BuildCommitSha=${GITHUB_SHA} -X main.BuildDate=$(BUILD_DT)" 83 | 84 | 85 | .PHONE: build-some-amd64-binaries 86 | build-some-amd64-binaries: 87 | go install github.com/oliver006/gox@master 88 | 89 | rm -rf .build | true 90 | 91 | export CGO_ENABLED=0 ; \ 92 | gox -os="linux windows" -arch="amd64" -verbose -rebuild -ldflags $(GO_LDFLAGS) -output ".build/redis_exporter-${GITHUB_REF_NAME}.{{.OS}}-{{.Arch}}/{{.Dir}}" && echo "done" 93 | 94 | 95 | .PHONE: build-all-binaries 96 | build-all-binaries: 97 | go install github.com/oliver006/gox@master 98 | 99 | rm -rf .build | true 100 | 101 | export CGO_ENABLED=0 ; \ 102 | gox -os="linux windows freebsd netbsd openbsd" -arch="amd64 386" -verbose -rebuild -ldflags $(GO_LDFLAGS) -output ".build/redis_exporter-${GITHUB_REF_NAME}.{{.OS}}-{{.Arch}}/{{.Dir}}" && \ 103 | gox -os="darwin solaris illumos" -arch="amd64" -verbose -rebuild -ldflags $(GO_LDFLAGS) -output ".build/redis_exporter-${GITHUB_REF_NAME}.{{.OS}}-{{.Arch}}/{{.Dir}}" && \ 104 | gox -os="darwin" -arch="arm64" -verbose -rebuild -ldflags $(GO_LDFLAGS) -output ".build/redis_exporter-${GITHUB_REF_NAME}.{{.OS}}-{{.Arch}}/{{.Dir}}" && \ 105 | gox -os="linux freebsd netbsd" -arch="arm" -verbose -rebuild -ldflags $(GO_LDFLAGS) -output ".build/redis_exporter-${GITHUB_REF_NAME}.{{.OS}}-{{.Arch}}/{{.Dir}}" && \ 106 | gox -os="linux" -arch="arm64 mips64 mips64le ppc64 ppc64le s390x" -verbose -rebuild -ldflags $(GO_LDFLAGS) -output ".build/redis_exporter-${GITHUB_REF_NAME}.{{.OS}}-{{.Arch}}/{{.Dir}}" && \ 107 | echo "done" 108 | -------------------------------------------------------------------------------- /contrib/collect_lists_length_growing.lua: -------------------------------------------------------------------------------- 1 | local result = {} 2 | 3 | local function lengthOfList (key) 4 | return redis.call("LLEN", key) 5 | end 6 | 7 | redis.call("SELECT", DB_NO) 8 | 9 | local keysPresent = redis.call("KEYS", "KEYS_PATTERN") 10 | 11 | if keysPresent ~= nil then 12 | for _,key in ipairs(keysPresent) do 13 | 14 | --error catching and status=true for success calls 15 | local status, retval = pcall(lengthOfList, key) 16 | 17 | if status == true then 18 | local keyName = "redis_list_length_" .. key 19 | local keyValue = retval .. "" 20 | table.insert(result, keyName) -- store the keyname 21 | table.insert(result, keyValue) --store the bit count 22 | end 23 | end 24 | end 25 | 26 | return result 27 | -------------------------------------------------------------------------------- /contrib/k8s-redis-and-exporter-deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: redis 6 | --- 7 | apiVersion: apps/v1 8 | kind: Deployment 9 | metadata: 10 | namespace: redis 11 | name: redis 12 | spec: 13 | replicas: 1 14 | selector: 15 | matchLabels: 16 | app: redis 17 | template: 18 | metadata: 19 | annotations: 20 | prometheus.io/scrape: "true" 21 | prometheus.io/port: "9121" 22 | labels: 23 | app: redis 24 | spec: 25 | containers: 26 | - name: redis 27 | image: redis:4 28 | resources: 29 | requests: 30 | cpu: 100m 31 | memory: 100Mi 32 | ports: 33 | - containerPort: 6379 34 | - name: redis-exporter 35 | image: oliver006/redis_exporter:latest 36 | securityContext: 37 | runAsUser: 59000 38 | runAsGroup: 59000 39 | allowPrivilegeEscalation: false 40 | capabilities: 41 | drop: 42 | - ALL 43 | resources: 44 | requests: 45 | cpu: 100m 46 | memory: 100Mi 47 | ports: 48 | - containerPort: 9121 49 | -------------------------------------------------------------------------------- /contrib/manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | buildpack: go_buildpack 3 | command: redis_exporter --use-cf-bindings --web.listen-address=:8080 4 | env: 5 | GOPACKAGENAME: main 6 | -------------------------------------------------------------------------------- /contrib/openshift-template.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Template 3 | labels: 4 | template: redis-exporter 5 | app: redis-exporter 6 | tier: redis 7 | metadata: 8 | annotations: 9 | openshift.io/display-name: Openshift Redis Exporter deployment template 10 | description: >- 11 | Deploy a Redis exporter for Prometheus into a specific namespace together with image stream 12 | tags: 'redis-exporter' 13 | name: redis-exporter 14 | parameters: 15 | - name: NAME 16 | description: The name of the application 17 | displayName: Name 18 | required: true 19 | value: redis-exporter 20 | - name: NAMESPACE 21 | description: The namespace of the application 22 | displayName: Namespace 23 | required: true 24 | - name: SOURCE_REPOSITORY_URL 25 | description: The URL of the repository with your application source code. 26 | displayName: Git Repository URL 27 | required: true 28 | value: 'https://github.com/oliver006/redis_exporter.git' 29 | - name: SOURCE_REPOSITORY_REF 30 | description: Set the branch name if you are not using master branch 31 | displayName: Git Reference 32 | value: master 33 | required: false 34 | - name: REDIS_ADDR 35 | description: Set the service names of the Redis instances that you like to export 36 | displayName: Redis Addresses 37 | required: true 38 | - name: REDIS_PASSWORD 39 | description: Set the password for the Redis instances that you like to export 40 | displayName: Redis Password 41 | required: false 42 | - name: REDIS_ALIAS 43 | description: Set the service alias of the Redis instances that you like to export 44 | displayName: Redis Alias 45 | required: false 46 | - name: REDIS_FILE 47 | description: Set the Redis file that contains one or more redis nodes, separated by newline 48 | displayName: Redis file 49 | required: false 50 | objects: 51 | 52 | - apiVersion: v1 53 | kind: ImageStream 54 | metadata: 55 | generation: 2 56 | labels: 57 | app: redis-exporter 58 | name: redis-exporter 59 | name: redis-exporter 60 | spec: 61 | dockerImageRepository: oliver006/redis_exporter 62 | 63 | - apiVersion: v1 64 | kind: DeploymentConfig 65 | metadata: 66 | labels: 67 | app: redis-exporter 68 | name: redis-exporter 69 | spec: 70 | replicas: 1 71 | selector: 72 | app: redis-exporter 73 | template: 74 | metadata: 75 | labels: 76 | app: redis-exporter 77 | spec: 78 | containers: 79 | - image: docker-registry.default.svc:5000/${NAMESPACE}/redis-exporter 80 | imagePullPolicy: Always 81 | name: redis-exporter 82 | ports: 83 | - containerPort: 9121 84 | env: 85 | - name: REDIS_ADDR 86 | value: "${REDIS_ADDR}" 87 | - name: REDIS_PASSWORD 88 | value: "${REDIS_PASSWORD}" 89 | - name: REDIS_ALIAS 90 | value: "${REDIS_ALIAS}" 91 | - name: REDIS_FILE 92 | value: "${REDIS_FILE}" 93 | resources: {} 94 | dnsPolicy: ClusterFirst 95 | restartPolicy: Always 96 | securityContext: {} 97 | terminationGracePeriodSeconds: 30 98 | test: false 99 | triggers: [] 100 | status: {} 101 | 102 | - apiVersion: v1 103 | kind: Service 104 | metadata: 105 | labels: 106 | name: redis-exporter 107 | role: service 108 | name: redis-exporter 109 | spec: 110 | ports: 111 | - port: 9121 112 | targetPort: 9121 113 | selector: 114 | app: "redis-exporter" 115 | -------------------------------------------------------------------------------- /contrib/redis-mixin/.gitignore: -------------------------------------------------------------------------------- 1 | alerts.yaml 2 | rules.yaml 3 | dashboards_out 4 | -------------------------------------------------------------------------------- /contrib/redis-mixin/Makefile: -------------------------------------------------------------------------------- 1 | JSONNET_FMT := jsonnetfmt -n 2 --max-blank-lines 2 --string-style s --comment-style s 2 | 3 | # no lint for now, fails with a lot of errors and needs cleaning up first 4 | all: deps fmt build clean 5 | 6 | deps: 7 | go install github.com/monitoring-mixins/mixtool/cmd/mixtool@master 8 | go install github.com/google/go-jsonnet/cmd/jsonnetfmt@latest 9 | 10 | 11 | fmt: 12 | find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ 13 | xargs -n 1 -- $(JSONNET_FMT) -i 14 | 15 | lint: 16 | find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ 17 | while read f; do \ 18 | $(JSONNET_FMT) "$$f" | diff -u "$$f" -; \ 19 | done 20 | 21 | mixtool lint mixin.libsonnet 22 | 23 | build: 24 | mixtool generate all mixin.libsonnet 25 | 26 | clean: 27 | rm -rf dashboards_out alerts.yaml rules.yaml 28 | -------------------------------------------------------------------------------- /contrib/redis-mixin/README.md: -------------------------------------------------------------------------------- 1 | # Redis 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 Redis Mixin is a set of configurable, reusable, and extensible alerts and 7 | dashboards based on the metrics exported by the Redis 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 >= 1.17 15 | # Using `go get` to install binaries is deprecated. 16 | $ go install github.com/monitoring-mixins/mixtool/cmd/mixtool@latest 17 | $ go install github.com/google/go-jsonnet/cmd/jsonnet@latest 18 | 19 | # go < 1.17 20 | $ go get github.com/monitoring-mixins/mixtool/cmd/mixtool 21 | $ go get github.com/google/go-jsonnet/cmd/jsonnetfmt 22 | ``` 23 | 24 | You can then build the Prometheus rules files `alerts.yaml` and 25 | `rules.yaml` and a directory `dashboard_out` with the JSON dashboard files 26 | for Grafana: 27 | ```bash 28 | $ make build 29 | ``` 30 | 31 | The mixin currently treats each redis instance independently - it has no notion of replication or clustering. We aim to support these concepts in future versions. The mixin dashboard is a fork of the one in the [contrib](contrib/) directory. 32 | 33 | For more advanced uses of mixins, see 34 | https://github.com/monitoring-mixins/docs. 35 | -------------------------------------------------------------------------------- /contrib/redis-mixin/alerts/redis.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | prometheusAlerts+:: { 3 | groups+: [ 4 | { 5 | name: 'redis', 6 | rules: [ 7 | { 8 | alert: 'RedisDown', 9 | expr: 'redis_up{%(redisExporterSelector)s} == 0' % $._config, 10 | 'for': '5m', 11 | labels: { 12 | severity: 'critical', 13 | }, 14 | annotations: { 15 | summary: 'Redis down (instance {{ $labels.instance }})', 16 | description: 'Redis instance is down\n VALUE = {{ $value }}\n LABELS: {{ $labels }}', 17 | }, 18 | }, 19 | { 20 | alert: 'RedisOutOfMemory', 21 | expr: 'redis_memory_used_bytes{%(redisExporterSelector)s} / redis_total_system_memory_bytes{%(redisExporterSelector)s} * 100 > 90' % $._config, 22 | 'for': '5m', 23 | labels: { 24 | severity: 'warning', 25 | }, 26 | annotations: { 27 | summary: 'Redis out of memory (instance {{ $labels.instance }})', 28 | description: 'Redis is running out of memory (> 90%)\n VALUE = {{ $value }}\n LABELS: {{ $labels }}', 29 | }, 30 | }, 31 | { 32 | alert: 'RedisTooManyConnections', 33 | expr: 'redis_connected_clients{%(redisExporterSelector)s} > %(redisConnectionsThreshold)s' % $._config, 34 | 'for': '5m', 35 | labels: { 36 | severity: 'warning', 37 | }, 38 | annotations: { 39 | summary: 'Redis too many connections (instance {{ $labels.instance }})', 40 | description: 'Redis instance has too many connections\n VALUE = {{ $value }}\n LABELS: {{ $labels }}', 41 | }, 42 | }, 43 | { 44 | alert: 'RedisClusterSlotFail', 45 | expr: 'redis_cluster_slots_fail{%(redisExporterSelector)s} > 0' % $._config, 46 | 'for': '5m', 47 | labels: { 48 | severity: 'warning', 49 | }, 50 | annotations: { 51 | summary: 'Number of hash slots mapping to a node in FAIL state (instance {{ $labels.instance }})', 52 | description: 'Redis cluster has slots fail\n VALUE = {{ $value }}\n LABELS: {{ $labels }}', 53 | }, 54 | }, 55 | { 56 | alert: 'RedisClusterSlotPfail', 57 | expr: 'redis_cluster_slots_pfail{%(redisExporterSelector)s} > 0' % $._config, 58 | 'for': '5m', 59 | labels: { 60 | severity: 'warning', 61 | }, 62 | annotations: { 63 | summary: 'Number of hash slots mapping to a node in PFAIL state (instance {{ $labels.instance }})', 64 | description: 'Redis cluster has slots pfail\n VALUE = {{ $value }}\n LABELS: {{ $labels }}', 65 | }, 66 | }, 67 | { 68 | alert: 'RedisClusterStateNotOk', 69 | expr: 'redis_cluster_state{%(redisExporterSelector)s} == 0' % $._config, 70 | 'for': '5m', 71 | labels: { 72 | severity: 'critical', 73 | }, 74 | annotations: { 75 | summary: 'Redis cluster state is not ok (instance {{ $labels.instance }})', 76 | description: 'Redis cluster is not ok\n VALUE = {{ $value }}\n LABELS: {{ $labels }}', 77 | }, 78 | }, 79 | ], 80 | }, 81 | ], 82 | }, 83 | } 84 | -------------------------------------------------------------------------------- /contrib/redis-mixin/config.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | _config+:: { 3 | redisConnectionsThreshold: '100', 4 | redisExporterSelector: 'job="redis"', 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /contrib/redis-mixin/dashboards/redis.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | grafanaDashboards+:: { 3 | 'redis-overview.json': (import 'redis-overview.json'), 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /contrib/redis-mixin/mixin.libsonnet: -------------------------------------------------------------------------------- 1 | (import 'alerts/redis.libsonnet') + 2 | (import 'rules/redis.libsonnet') + 3 | (import 'dashboards/redis.libsonnet') + 4 | (import 'config.libsonnet') 5 | -------------------------------------------------------------------------------- /contrib/redis-mixin/rules/redis.libsonnet: -------------------------------------------------------------------------------- 1 | { 2 | prometheusRules+:: { 3 | groups+: [ 4 | { 5 | name: 'redis.rules', 6 | rules: [ 7 | { 8 | record: 'redis_memory_fragmentation_ratio', 9 | expr: 'redis_memory_used_rss_bytes{%(redisExporterSelector)s} / redis_memory_used_bytes{%(redisExporterSelector)s}' % $._config, 10 | }, 11 | ], 12 | }, 13 | ], 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /contrib/sample-pwd-file.json: -------------------------------------------------------------------------------- 1 | { 2 | "redis://localhost:16379": "", 3 | "redis://exporter@localhost:16390": "exporter-password", 4 | "redis://localhost:16380": "redis-password" 5 | } 6 | -------------------------------------------------------------------------------- /contrib/sample-pwd-file.json-malformed: -------------------------------------------------------------------------------- 1 | { 2 | "redis://redis6:6379": "", 3 | "redis://pwd-redis5:6380": "redis-password" 4 | 5 | -------------------------------------------------------------------------------- /contrib/sample_collect_script.lua: -------------------------------------------------------------------------------- 1 | -- Example collect script for -script option 2 | -- This returns a Lua table with alternating keys and values. 3 | -- Both keys and values must be strings, similar to a HGETALL result. 4 | -- More info about Redis Lua scripting: https://valkey.io/commands/eval 5 | 6 | local result = {} 7 | 8 | -- Add all keys and values from some hash in db 5 9 | redis.call("SELECT", 5) 10 | local r = redis.call("HGETALL", "some-hash-with-stats") 11 | if r ~= nil then 12 | for _,v in ipairs(r) do 13 | table.insert(result, v) -- alternating keys and values 14 | end 15 | end 16 | 17 | -- Set foo to 42 18 | table.insert(result, "foo") 19 | table.insert(result, "42") -- note the string, use tostring() if needed 20 | 21 | return result 22 | -------------------------------------------------------------------------------- /contrib/tls/gen-test-certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Generate test certificates: 4 | # 5 | # ca.{crt,key} Self signed CA certificate. 6 | # redis.{crt,key} A certificate with no key usage/policy restrictions. 7 | 8 | dir=`dirname $0` 9 | 10 | # Generate CA 11 | openssl genrsa -out ${dir}/ca.key 4096 12 | openssl req \ 13 | -x509 -new -nodes -sha256 \ 14 | -key ${dir}/ca.key \ 15 | -days 3650 \ 16 | -subj '/O=redis_exporter/CN=Certificate Authority' \ 17 | -out ${dir}/ca.crt 18 | 19 | # Generate cert 20 | openssl genrsa -out ${dir}/redis.key 2048 21 | openssl req \ 22 | -new -sha256 \ 23 | -subj "/O=redis_exporter/CN=localhost" \ 24 | -key ${dir}/redis.key | \ 25 | openssl x509 \ 26 | -req -sha256 \ 27 | -CA ${dir}/ca.crt \ 28 | -CAkey ${dir}/ca.key \ 29 | -CAserial ${dir}/ca.txt \ 30 | -CAcreateserial \ 31 | -days 3650 \ 32 | -out ${dir}/redis.crt 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | redis7: 4 | image: redis:7.4 5 | command: "redis-server --enable-debug-command yes --protected-mode no" 6 | ports: 7 | - "16385:6379" 8 | - "6379:6379" 9 | 10 | redis8: 11 | image: redis:8.0 12 | ports: 13 | - "16388:6379" 14 | 15 | redis7-tls: 16 | image: redis:7.4 17 | volumes: 18 | - ./contrib/tls:/tls 19 | command: | 20 | redis-server --enable-debug-command yes --protected-mode no 21 | --tls-port 6379 --port 0 22 | --tls-cert-file /tls/redis.crt 23 | --tls-key-file /tls/redis.key 24 | --tls-ca-cert-file /tls/ca.crt 25 | ports: 26 | - "16387:6379" 27 | 28 | valkey8: 29 | image: valkey/valkey:8 30 | command: "valkey-server --enable-debug-command yes --protected-mode no" 31 | ports: 32 | - "16382:6379" 33 | 34 | valkey8-tls: 35 | image: valkey/valkey:8 36 | volumes: 37 | - ./contrib/tls:/tls 38 | command: | 39 | valkey-server --enable-debug-command yes --protected-mode no 40 | --tls-port 6379 --port 0 41 | --tls-cert-file /tls/redis.crt 42 | --tls-key-file /tls/redis.key 43 | --tls-ca-cert-file /tls/ca.crt 44 | ports: 45 | - "16386:6379" 46 | 47 | valkey7: 48 | image: valkey/valkey:7.2 49 | command: "valkey-server --enable-debug-command yes --protected-mode no" 50 | ports: 51 | - "16384:6379" 52 | 53 | redis6: 54 | image: redis:6.2 55 | command: "redis-server --protected-mode no" 56 | ports: 57 | - "16379:6379" 58 | 59 | redis5: 60 | image: redis:5 61 | command: "redis-server" 62 | ports: 63 | - "16383:6379" 64 | 65 | pwd-redis7: 66 | image: redis:7.4 67 | command: "redis-server --protected-mode no --requirepass redis-password" 68 | ports: 69 | - "16380:6379" 70 | 71 | pwd-user-redis7: 72 | image: redis:7.4 73 | command: "redis-server --protected-mode no --requirepass dummy --user exporter on +CLIENT +INFO +SELECT +SLOWLOG +LATENCY '>exporter-password'" 74 | ports: 75 | - "16390:6379" 76 | 77 | redis-2-8: 78 | image: redis:2.8 79 | command: "redis-server" 80 | ports: 81 | - "16381:6379" 82 | 83 | keydb-01: 84 | image: "eqalpha/keydb:x86_64_v6.3.4" 85 | command: "keydb-server --protected-mode no" 86 | ports: 87 | - "16401:6379" 88 | 89 | keydb-02: 90 | image: "eqalpha/keydb:x86_64_v6.3.1" 91 | command: "keydb-server --protected-mode no --active-replica yes --replicaof keydb-01 6379" 92 | ports: 93 | - "16402:6379" 94 | 95 | redis-cluster: 96 | image: grokzen/redis-cluster:6.2.14 97 | environment: 98 | - IP=0.0.0.0 99 | ports: 100 | - 7000-7005:7000-7005 101 | - 17000-17005:7000-7005 102 | 103 | redis-cluster-password: 104 | image: bitnami/redis-cluster:7.4 105 | environment: 106 | - REDIS_PORT_NUMBER=7006 107 | - REDIS_PASSWORD=redis-password 108 | - REDIS_CLUSTER_CREATOR=yes 109 | - REDIS_NODES=redis-cluster-password:7006 110 | ports: 111 | - "17006:7006" 112 | 113 | redis-sentinel: 114 | image: docker.io/bitnami/redis-sentinel:6.2-debian-10 115 | environment: 116 | - REDIS_MASTER_HOST=redis6 117 | ports: 118 | - "26379:26379" 119 | 120 | tile38: 121 | image: tile38/tile38:latest 122 | ports: 123 | - "19851:9851" 124 | 125 | redis-stack: 126 | image: redis/redis-stack-server:7.4.0-v0 127 | ports: 128 | - "36379:6379" 129 | -------------------------------------------------------------------------------- /exporter/clients.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gomodule/redigo/redis" 10 | "github.com/prometheus/client_golang/prometheus" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type ClientInfo struct { 15 | Id, 16 | Name, 17 | User, 18 | Flags, 19 | Db, 20 | Host, 21 | Port, 22 | Resp string 23 | CreatedAt, 24 | IdleSince, 25 | Sub, 26 | Psub, 27 | Ssub, 28 | Watch, 29 | Qbuf, 30 | QbufFree, 31 | Obl, 32 | Oll, 33 | OMem, 34 | TotMem int64 35 | } 36 | 37 | /* 38 | Valid Examples 39 | id=11 addr=127.0.0.1:63508 fd=8 name= age=6321 idle=6320 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=setex user=default resp=2 40 | id=14 addr=127.0.0.1:64958 fd=9 name= age=5 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client user=default resp=3 41 | id=40253233 addr=fd40:1481:21:dbe0:7021:300:a03:1a06:44426 fd=19 name= age=782 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 argv-mem=10 obl=0 oll=0 omem=0 tot-mem=61466 ow=0 owmem=0 events=r cmd=client user=default lib-name=redis-py lib-ver=5.0.1 numops=9 42 | */ 43 | func parseClientListString(clientInfo string) (*ClientInfo, bool) { 44 | if matched, _ := regexp.MatchString(`^id=\d+ addr=\S+`, clientInfo); !matched { 45 | return nil, false 46 | } 47 | connectedClient := ClientInfo{} 48 | connectedClient.Ssub = -1 // mark it as missing - introduced in Redis 7.0.3 49 | connectedClient.Watch = -1 // mark it as missing - introduced in Redis 7.4 50 | for _, kvPart := range strings.Split(clientInfo, " ") { 51 | vPart := strings.Split(kvPart, "=") 52 | if len(vPart) != 2 { 53 | log.Debugf("Invalid format for client list string, got: %s", kvPart) 54 | return nil, false 55 | } 56 | 57 | switch vPart[0] { 58 | case "id": 59 | connectedClient.Id = vPart[1] 60 | case "name": 61 | connectedClient.Name = vPart[1] 62 | case "user": 63 | connectedClient.User = vPart[1] 64 | case "age": 65 | createdAt, err := durationFieldToTimestamp(vPart[1]) 66 | if err != nil { 67 | log.Debugf("could not parse 'age' field(%s): %s", vPart[1], err.Error()) 68 | return nil, false 69 | } 70 | connectedClient.CreatedAt = createdAt 71 | case "idle": 72 | idleSinceTs, err := durationFieldToTimestamp(vPart[1]) 73 | if err != nil { 74 | log.Debugf("could not parse 'idle' field(%s): %s", vPart[1], err.Error()) 75 | return nil, false 76 | } 77 | connectedClient.IdleSince = idleSinceTs 78 | case "flags": 79 | connectedClient.Flags = vPart[1] 80 | case "db": 81 | connectedClient.Db = vPart[1] 82 | case "sub": 83 | connectedClient.Sub, _ = strconv.ParseInt(vPart[1], 10, 64) 84 | case "psub": 85 | connectedClient.Psub, _ = strconv.ParseInt(vPart[1], 10, 64) 86 | case "ssub": 87 | connectedClient.Ssub, _ = strconv.ParseInt(vPart[1], 10, 64) 88 | case "watch": 89 | connectedClient.Watch, _ = strconv.ParseInt(vPart[1], 10, 64) 90 | case "qbuf": 91 | connectedClient.Qbuf, _ = strconv.ParseInt(vPart[1], 10, 64) 92 | case "qbuf-free": 93 | connectedClient.QbufFree, _ = strconv.ParseInt(vPart[1], 10, 64) 94 | case "obl": 95 | connectedClient.Obl, _ = strconv.ParseInt(vPart[1], 10, 64) 96 | case "oll": 97 | connectedClient.Oll, _ = strconv.ParseInt(vPart[1], 10, 64) 98 | case "omem": 99 | connectedClient.OMem, _ = strconv.ParseInt(vPart[1], 10, 64) 100 | case "tot-mem": 101 | connectedClient.TotMem, _ = strconv.ParseInt(vPart[1], 10, 64) 102 | case "addr": 103 | hostPortString := strings.Split(vPart[1], ":") 104 | if len(hostPortString) < 2 { 105 | log.Debug("Invalid value for 'addr' found in client info") 106 | return nil, false 107 | } 108 | connectedClient.Host = strings.Join(hostPortString[:len(hostPortString)-1], ":") 109 | connectedClient.Port = hostPortString[len(hostPortString)-1] 110 | case "resp": 111 | connectedClient.Resp = vPart[1] 112 | } 113 | } 114 | 115 | return &connectedClient, true 116 | } 117 | 118 | func durationFieldToTimestamp(field string) (int64, error) { 119 | parsed, err := strconv.ParseInt(field, 10, 64) 120 | if err != nil { 121 | return 0, err 122 | } 123 | return time.Now().Unix() - parsed, nil 124 | } 125 | 126 | func (e *Exporter) extractConnectedClientMetrics(ch chan<- prometheus.Metric, c redis.Conn) { 127 | reply, err := redis.String(doRedisCmd(c, "CLIENT", "LIST")) 128 | if err != nil { 129 | log.Errorf("CLIENT LIST err: %s", err) 130 | return 131 | } 132 | e.parseConnectedClientMetrics(reply, ch) 133 | } 134 | 135 | func (e *Exporter) parseConnectedClientMetrics(input string, ch chan<- prometheus.Metric) { 136 | 137 | for _, s := range strings.Split(input, "\n") { 138 | info, ok := parseClientListString(s) 139 | if !ok { 140 | log.Debugf("parseClientListString( %s ) - couldn';t parse input", s) 141 | continue 142 | } 143 | clientInfoLabels := []string{"id", "name", "flags", "db", "host"} 144 | clientInfoLabelValues := []string{info.Id, info.Name, info.Flags, info.Db, info.Host} 145 | 146 | if e.options.ExportClientsInclPort { 147 | clientInfoLabels = append(clientInfoLabels, "port") 148 | clientInfoLabelValues = append(clientInfoLabelValues, info.Port) 149 | } 150 | 151 | if user := info.User; user != "" { 152 | clientInfoLabels = append(clientInfoLabels, "user") 153 | clientInfoLabelValues = append(clientInfoLabelValues, user) 154 | } 155 | 156 | // introduced in Redis 7.0 157 | if resp := info.Resp; resp != "" { 158 | clientInfoLabels = append(clientInfoLabels, "resp") 159 | clientInfoLabelValues = append(clientInfoLabelValues, resp) 160 | } 161 | 162 | e.createMetricDescription("connected_client_info", clientInfoLabels) 163 | e.registerConstMetricGauge( 164 | ch, "connected_client_info", 1.0, 165 | clientInfoLabelValues..., 166 | ) 167 | 168 | clientBaseLabels := []string{"id", "name"} 169 | clientBaseLabelsValues := []string{info.Id, info.Name} 170 | 171 | for _, metricName := range []string{ 172 | "connected_client_output_buffer_memory_usage_bytes", 173 | "connected_client_total_memory_consumed_bytes", 174 | "connected_client_created_at_timestamp", 175 | "connected_client_idle_since_timestamp", 176 | "connected_client_channel_subscriptions_count", 177 | "connected_client_pattern_matching_subscriptions_count", 178 | "connected_client_query_buffer_length_bytes", 179 | "connected_client_query_buffer_free_space_bytes", 180 | "connected_client_output_buffer_length_bytes", 181 | "connected_client_output_list_length", 182 | } { 183 | e.createMetricDescription(metricName, clientBaseLabels) 184 | } 185 | 186 | e.registerConstMetricGauge( 187 | ch, "connected_client_output_buffer_memory_usage_bytes", float64(info.OMem), 188 | clientBaseLabelsValues..., 189 | ) 190 | 191 | e.registerConstMetricGauge( 192 | ch, "connected_client_total_memory_consumed_bytes", float64(info.TotMem), 193 | clientBaseLabelsValues..., 194 | ) 195 | 196 | e.registerConstMetricGauge( 197 | ch, "connected_client_created_at_timestamp", float64(info.CreatedAt), 198 | clientBaseLabelsValues..., 199 | ) 200 | 201 | e.registerConstMetricGauge( 202 | ch, "connected_client_idle_since_timestamp", float64(info.IdleSince), 203 | clientBaseLabelsValues..., 204 | ) 205 | 206 | e.registerConstMetricGauge( 207 | ch, "connected_client_channel_subscriptions_count", float64(info.Sub), 208 | clientBaseLabelsValues..., 209 | ) 210 | 211 | e.registerConstMetricGauge( 212 | ch, "connected_client_pattern_matching_subscriptions_count", float64(info.Psub), 213 | clientBaseLabelsValues..., 214 | ) 215 | 216 | e.registerConstMetricGauge( 217 | ch, "connected_client_query_buffer_length_bytes", float64(info.Qbuf), 218 | clientBaseLabelsValues..., 219 | ) 220 | 221 | e.registerConstMetricGauge( 222 | ch, "connected_client_query_buffer_free_space_bytes", float64(info.QbufFree), 223 | clientBaseLabelsValues..., 224 | ) 225 | 226 | e.registerConstMetricGauge( 227 | ch, "connected_client_output_buffer_length_bytes", float64(info.Obl), 228 | clientBaseLabelsValues..., 229 | ) 230 | 231 | e.registerConstMetricGauge( 232 | ch, "connected_client_output_list_length", float64(info.Oll), 233 | clientBaseLabelsValues..., 234 | ) 235 | 236 | if info.Ssub != -1 { 237 | e.createMetricDescription("connected_client_shard_channel_subscriptions_count", clientBaseLabels) 238 | e.registerConstMetricGauge( 239 | ch, "connected_client_shard_channel_subscriptions_count", float64(info.Ssub), 240 | clientBaseLabelsValues..., 241 | ) 242 | } 243 | if info.Watch != -1 { 244 | e.createMetricDescription("connected_client_shard_channel_watched_keys", clientBaseLabels) 245 | e.registerConstMetricGauge( 246 | ch, "connected_client_shard_channel_watched_keys", float64(info.Watch), 247 | clientBaseLabelsValues..., 248 | ) 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /exporter/clients_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | ) 11 | 12 | func TestDurationFieldToTimestamp(t *testing.T) { 13 | nowTs := time.Now().Unix() 14 | for _, tst := range []struct { 15 | in string 16 | expectedOk bool 17 | expectedVal int64 18 | }{ 19 | { 20 | in: "123", 21 | expectedOk: true, 22 | expectedVal: nowTs - 123, 23 | }, 24 | { 25 | in: "0", 26 | expectedOk: true, 27 | expectedVal: nowTs - 0, 28 | }, 29 | { 30 | in: "abc", 31 | expectedOk: false, 32 | }, 33 | } { 34 | res, err := durationFieldToTimestamp(tst.in) 35 | if err == nil && !tst.expectedOk { 36 | t.Fatalf("expected not ok, but got no error, input: [%s]", tst.in) 37 | } else if err != nil && tst.expectedOk { 38 | t.Fatalf("expected ok, but got error: %s, input: [%s]", err, tst.in) 39 | } 40 | if tst.expectedOk { 41 | if res != tst.expectedVal { 42 | t.Fatalf("expected %d, but got: %d", tst.expectedVal, res) 43 | } 44 | } 45 | } 46 | } 47 | 48 | func TestParseClientListString(t *testing.T) { 49 | convertDurationToTimestampInt64 := func(duration string) int64 { 50 | ts, err := durationFieldToTimestamp(duration) 51 | if err != nil { 52 | panic(err) 53 | } 54 | return ts 55 | } 56 | 57 | tsts := []struct { 58 | in string 59 | expectedOk bool 60 | expectedInfo ClientInfo 61 | }{ 62 | { 63 | in: "id=11 addr=127.0.0.1:63508 fd=8 name= age=6321 idle=6320 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=3 oll=8 omem=0 tot-mem=0 events=r cmd=setex", 64 | expectedOk: true, 65 | expectedInfo: ClientInfo{Id: "11", CreatedAt: convertDurationToTimestampInt64("6321"), IdleSince: convertDurationToTimestampInt64("6320"), Flags: "N", Db: "0", Ssub: -1, Watch: -1, Obl: 3, Oll: 8, OMem: 0, TotMem: 0, Host: "127.0.0.1", Port: "63508"}, 66 | }, { 67 | in: "id=14 addr=127.0.0.1:64958 fd=9 name=foo age=5 idle=0 flags=N db=1 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 tot-mem=0 events=r cmd=client", 68 | expectedOk: true, 69 | expectedInfo: ClientInfo{Id: "14", Name: "foo", CreatedAt: convertDurationToTimestampInt64("5"), IdleSince: convertDurationToTimestampInt64("0"), Flags: "N", Db: "1", Ssub: -1, Watch: -1, Qbuf: 26, QbufFree: 32742, OMem: 0, TotMem: 0, Host: "127.0.0.1", Port: "64958"}, 70 | }, { 71 | in: "id=14 addr=127.0.0.1:64959 fd=9 name= age=5 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 tot-mem=0 events=r cmd=client user=default resp=3", 72 | expectedOk: true, 73 | expectedInfo: ClientInfo{Id: "14", CreatedAt: convertDurationToTimestampInt64("5"), IdleSince: convertDurationToTimestampInt64("0"), Flags: "N", Db: "0", Ssub: -1, Watch: -1, Qbuf: 26, QbufFree: 32742, OMem: 0, TotMem: 0, Host: "127.0.0.1", Port: "64959", User: "default", Resp: "3"}, 74 | }, { 75 | in: "id=40253233 addr=fd40:1481:21:dbe0:7021:300:a03:1a06:44426 fd=19 name= age=782 idle=0 flags=N db=0 sub=896 psub=18 ssub=17 watch=3 multi=-1 qbuf=26 qbuf-free=32742 argv-mem=10 obl=0 oll=555 omem=0 tot-mem=61466 ow=0 owmem=0 events=r cmd=client user=default lib-name=redis-py lib-ver=5.0.1 numops=9", 76 | expectedOk: true, 77 | expectedInfo: ClientInfo{Id: "40253233", CreatedAt: convertDurationToTimestampInt64("782"), IdleSince: convertDurationToTimestampInt64("0"), Flags: "N", Db: "0", Sub: 896, Psub: 18, Ssub: 17, Watch: 3, Qbuf: 26, QbufFree: 32742, Oll: 555, OMem: 0, TotMem: 61466, Host: "fd40:1481:21:dbe0:7021:300:a03:1a06", Port: "44426", User: "default"}, 78 | }, { 79 | in: "id=14 addr=127.0.0.1:64958 fd=9 name=foo age=ABCDE idle=0 flags=N db=1 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 tot-mem=0 events=r cmd=client", 80 | expectedOk: false, 81 | }, { 82 | in: "id=14 addr=127.0.0.1:64958 fd=9 name=foo age=5 idle=NOPE flags=N db=1 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 tot-mem=0 events=r cmd=client", 83 | expectedOk: false, 84 | }, { 85 | in: "id=14 addr=127.0.0.1:64958 sub=ERR", 86 | expectedOk: true, 87 | expectedInfo: ClientInfo{Id: "14", Sub: 0, Ssub: -1, Watch: -1, Host: "127.0.0.1", Port: "64958"}, 88 | }, { 89 | in: "id=14 addr=127.0.0.1:64958 psub=ERR", 90 | expectedOk: true, 91 | expectedInfo: ClientInfo{Id: "14", Psub: 0, Ssub: -1, Watch: -1, Host: "127.0.0.1", Port: "64958"}, 92 | }, { 93 | in: "id=14 addr=127.0.0.1:64958 ssub=ERR", 94 | expectedOk: true, 95 | expectedInfo: ClientInfo{Id: "14", Ssub: 0, Watch: -1, Host: "127.0.0.1", Port: "64958"}, 96 | }, { 97 | in: "id=14 addr=127.0.0.1:64958 watch=ERR", 98 | expectedOk: true, 99 | expectedInfo: ClientInfo{Id: "14", Ssub: -1, Watch: 0, Host: "127.0.0.1", Port: "64958"}, 100 | }, { 101 | in: "id=14 addr=127.0.0.1:64958 qbuf=ERR", 102 | expectedOk: true, 103 | expectedInfo: ClientInfo{Id: "14", Ssub: -1, Watch: -1, Qbuf: 0, Host: "127.0.0.1", Port: "64958"}, 104 | }, { 105 | in: "id=14 addr=127.0.0.1:64958 qbuf-free=ERR", 106 | expectedOk: true, 107 | expectedInfo: ClientInfo{Id: "14", Ssub: -1, Watch: -1, QbufFree: 0, Host: "127.0.0.1", Port: "64958"}, 108 | }, { 109 | in: "id=14 addr=127.0.0.1:64958 obl=ERR", 110 | expectedOk: true, 111 | expectedInfo: ClientInfo{Id: "14", Ssub: -1, Watch: -1, Obl: 0, Host: "127.0.0.1", Port: "64958"}, 112 | }, { 113 | in: "id=14 addr=127.0.0.1:64958 oll=ERR", 114 | expectedOk: true, 115 | expectedInfo: ClientInfo{Id: "14", Ssub: -1, Watch: -1, Oll: 0, Host: "127.0.0.1", Port: "64958"}, 116 | }, { 117 | in: "id=14 addr=127.0.0.1:64958 omem=ERR", 118 | expectedOk: true, 119 | expectedInfo: ClientInfo{Id: "14", Ssub: -1, Watch: -1, OMem: 0, Host: "127.0.0.1", Port: "64958"}, 120 | }, { 121 | in: "id=14 addr=127.0.0.1:64958 tot-mem=ERR", 122 | expectedOk: true, 123 | expectedInfo: ClientInfo{Id: "14", Ssub: -1, Watch: -1, TotMem: 0, Host: "127.0.0.1", Port: "64958"}, 124 | }, { 125 | in: "", 126 | expectedOk: false, 127 | }, 128 | } 129 | 130 | for _, tst := range tsts { 131 | info, ok := parseClientListString(tst.in) 132 | if !tst.expectedOk { 133 | if ok { 134 | t.Errorf("expected NOT ok, but got ok, input: %s", tst.in) 135 | } 136 | continue 137 | } 138 | 139 | if *info != tst.expectedInfo { 140 | t.Errorf("TestParseClientListString( %s ) error. Given: %#v Wanted: %#v", tst.in, info, tst.expectedInfo) 141 | } 142 | } 143 | } 144 | 145 | func TestExportClientList(t *testing.T) { 146 | for _, isExportClientList := range []bool{true, false} { 147 | e := getTestExporterWithOptions(Options{ 148 | Namespace: "test", Registry: prometheus.NewRegistry(), 149 | ExportClientList: isExportClientList, 150 | }) 151 | 152 | chM := make(chan prometheus.Metric) 153 | go func() { 154 | e.Collect(chM) 155 | close(chM) 156 | }() 157 | 158 | tsts := []struct { 159 | in string 160 | found bool 161 | }{ 162 | {in: "connected_client_info"}, 163 | {in: "connected_client_output_buffer_memory_usage_bytes"}, 164 | {in: "connected_client_total_memory_consumed_bytes"}, 165 | {in: "connected_client_created_at_timestamp"}, 166 | {in: "connected_client_idle_since_timestamp"}, 167 | {in: "connected_client_channel_subscriptions_count"}, 168 | {in: "connected_client_pattern_matching_subscriptions_count"}, 169 | {in: "connected_client_query_buffer_length_bytes"}, 170 | {in: "connected_client_query_buffer_free_space_bytes"}, 171 | {in: "connected_client_output_buffer_length_bytes"}, 172 | {in: "connected_client_output_list_length"}, 173 | {in: "connected_client_shard_channel_subscriptions_count"}, 174 | {in: "connected_client_info"}, 175 | } 176 | for m := range chM { 177 | desc := m.Desc().String() 178 | for i := range tsts { 179 | if strings.Contains(desc, tsts[i].in) { 180 | tsts[i].found = true 181 | } 182 | } 183 | } 184 | 185 | for _, tst := range tsts { 186 | if isExportClientList && !tst.found { 187 | t.Errorf("%s was *not* found in isExportClientList metrics but expected", tst.in) 188 | } else if !isExportClientList && tst.found { 189 | t.Errorf("%s was *found* in isExportClientList metrics but *not* expected", tst.in) 190 | } 191 | } 192 | } 193 | } 194 | 195 | /* 196 | some metrics are only in redis 7 but not yet in valkey 7.2 197 | like "connected_client_shard_channel_watched_keys" 198 | */ 199 | func TestExportClientListRedis7(t *testing.T) { 200 | redisSevenAddr := os.Getenv("TEST_REDIS7_URI") 201 | if redisSevenAddr == "" { 202 | t.Skipf("Skipping TestExportClientListRedis7, env var TEST_REDIS7_URI not set") 203 | } 204 | 205 | e := getTestExporterWithAddrAndOptions(redisSevenAddr, Options{ 206 | Namespace: "test", Registry: prometheus.NewRegistry(), 207 | ExportClientList: true, 208 | }) 209 | 210 | chM := make(chan prometheus.Metric) 211 | go func() { 212 | e.Collect(chM) 213 | close(chM) 214 | }() 215 | 216 | tsts := []struct { 217 | in string 218 | found bool 219 | }{ 220 | { 221 | in: "connected_client_shard_channel_subscriptions_count", 222 | }, { 223 | in: "connected_client_shard_channel_watched_keys", 224 | }, 225 | } 226 | for m := range chM { 227 | desc := m.Desc().String() 228 | for i := range tsts { 229 | if strings.Contains(desc, tsts[i].in) { 230 | tsts[i].found = true 231 | } 232 | } 233 | } 234 | 235 | for _, tst := range tsts { 236 | if !tst.found { 237 | t.Errorf(`%s was *not* found in isExportClientList metrics but expected`, tst.in) 238 | } 239 | } 240 | } 241 | 242 | func TestExportClientListInclPort(t *testing.T) { 243 | for _, inclPort := range []bool{true, false} { 244 | e := getTestExporterWithOptions(Options{ 245 | Namespace: "test", Registry: prometheus.NewRegistry(), 246 | ExportClientList: true, 247 | ExportClientsInclPort: inclPort, 248 | }) 249 | 250 | chM := make(chan prometheus.Metric) 251 | go func() { 252 | e.Collect(chM) 253 | close(chM) 254 | }() 255 | 256 | found := false 257 | for m := range chM { 258 | desc := m.Desc().String() 259 | if strings.Contains(desc, "connected_client_info") { 260 | if strings.Contains(desc, "port") { 261 | found = true 262 | } 263 | } 264 | } 265 | 266 | if inclPort && !found { 267 | t.Errorf(`connected_client_info did *not* include "port" in isExportClientList metrics but was expected`) 268 | } else if !inclPort && found { 269 | t.Errorf(`connected_client_info did *include* "port" in isExportClientList metrics but was *not* expected`) 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /exporter/http.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "crypto/subtle" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | "github.com/prometheus/client_golang/prometheus/promhttp" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | func (e *Exporter) ServeHTTP(w http.ResponseWriter, r *http.Request) { 18 | if err := e.verifyBasicAuth(r.BasicAuth()); err != nil { 19 | w.Header().Set("WWW-Authenticate", `Basic realm="redis-exporter, charset=UTF-8"`) 20 | http.Error(w, err.Error(), http.StatusUnauthorized) 21 | return 22 | } 23 | 24 | e.mux.ServeHTTP(w, r) 25 | } 26 | 27 | func (e *Exporter) healthHandler(w http.ResponseWriter, r *http.Request) { 28 | _, _ = w.Write([]byte(`ok`)) 29 | } 30 | 31 | func (e *Exporter) indexHandler(w http.ResponseWriter, r *http.Request) { 32 | _, _ = w.Write([]byte(` 33 | Redis Exporter ` + e.buildInfo.Version + ` 34 | 35 |

Redis Exporter ` + e.buildInfo.Version + `

36 |

Metrics

37 | 38 | 39 | `)) 40 | } 41 | 42 | func (e *Exporter) scrapeHandler(w http.ResponseWriter, r *http.Request) { 43 | target := r.URL.Query().Get("target") 44 | if target == "" { 45 | http.Error(w, "'target' parameter must be specified", http.StatusBadRequest) 46 | e.targetScrapeRequestErrors.Inc() 47 | return 48 | } 49 | 50 | if !strings.Contains(target, "://") { 51 | target = "redis://" + target 52 | } 53 | 54 | u, err := url.Parse(target) 55 | if err != nil { 56 | http.Error(w, fmt.Sprintf("Invalid 'target' parameter, parse err: %ck ", err), http.StatusBadRequest) 57 | e.targetScrapeRequestErrors.Inc() 58 | return 59 | } 60 | 61 | opts := e.options 62 | 63 | // get rid of username/password info in "target" so users don't send them in plain text via http 64 | // and save "user" in options so we can use it later when connecting to the redis instance 65 | // the password will be looked up from the password file 66 | if u.User != nil { 67 | opts.User = u.User.Username() 68 | u.User = nil 69 | } 70 | target = u.String() 71 | 72 | if ck := r.URL.Query().Get("check-keys"); ck != "" { 73 | opts.CheckKeys = ck 74 | } 75 | 76 | if csk := r.URL.Query().Get("check-single-keys"); csk != "" { 77 | opts.CheckSingleKeys = csk 78 | } 79 | 80 | if cs := r.URL.Query().Get("check-streams"); cs != "" { 81 | opts.CheckStreams = cs 82 | } 83 | 84 | if css := r.URL.Query().Get("check-single-streams"); css != "" { 85 | opts.CheckSingleStreams = css 86 | } 87 | 88 | if cntk := r.URL.Query().Get("count-keys"); cntk != "" { 89 | opts.CountKeys = cntk 90 | } 91 | 92 | registry := prometheus.NewRegistry() 93 | opts.Registry = registry 94 | 95 | _, err = NewRedisExporter(target, opts) 96 | if err != nil { 97 | http.Error(w, "NewRedisExporter() err: err", http.StatusBadRequest) 98 | e.targetScrapeRequestErrors.Inc() 99 | return 100 | } 101 | 102 | promhttp.HandlerFor( 103 | registry, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}, 104 | ).ServeHTTP(w, r) 105 | } 106 | 107 | func (e *Exporter) discoverClusterNodesHandler(w http.ResponseWriter, r *http.Request) { 108 | if !e.options.IsCluster { 109 | http.Error(w, "The discovery endpoint is only available on a redis cluster", http.StatusBadRequest) 110 | return 111 | } 112 | 113 | c, err := e.connectToRedisCluster() 114 | if err != nil { 115 | http.Error(w, fmt.Sprintf("Couldn't connect to redis cluster: %s", err), http.StatusInternalServerError) 116 | return 117 | } 118 | defer c.Close() 119 | 120 | nodes, err := e.getClusterNodes(c) 121 | if err != nil { 122 | http.Error(w, fmt.Sprintf("Failed to fetch cluster nodes: %s", err), http.StatusInternalServerError) 123 | return 124 | } 125 | 126 | discovery := []struct { 127 | Targets []string `json:"targets"` 128 | Labels map[string]string `json:"labels"` 129 | }{ 130 | { 131 | Targets: make([]string, len(nodes)), 132 | Labels: make(map[string]string, 0), 133 | }, 134 | } 135 | 136 | isTls := strings.HasPrefix(e.redisAddr, "rediss://") 137 | for i, node := range nodes { 138 | if isTls { 139 | discovery[0].Targets[i] = "rediss://" + node 140 | } else { 141 | discovery[0].Targets[i] = "redis://" + node 142 | } 143 | } 144 | 145 | data, err := json.MarshalIndent(discovery, "", " ") 146 | if err != nil { 147 | http.Error(w, fmt.Sprintf("Failed to marshal discovery data: %s", err), http.StatusInternalServerError) 148 | return 149 | } 150 | 151 | w.Header().Set("Content-Type", "application/json") 152 | _, _ = w.Write(data) 153 | } 154 | 155 | func (e *Exporter) reloadPwdFile(w http.ResponseWriter, r *http.Request) { 156 | if e.options.RedisPwdFile == "" { 157 | http.Error(w, "There is no pwd file specified", http.StatusBadRequest) 158 | return 159 | } 160 | log.Debugf("Reload redisPwdFile") 161 | passwordMap, err := LoadPwdFile(e.options.RedisPwdFile) 162 | if err != nil { 163 | log.Errorf("Error reloading redis passwords from file %s, err: %s", e.options.RedisPwdFile, err) 164 | http.Error(w, "failed to reload passwords file: "+err.Error(), http.StatusInternalServerError) 165 | return 166 | } 167 | e.Lock() 168 | e.options.PasswordMap = passwordMap 169 | e.Unlock() 170 | _, _ = w.Write([]byte(`ok`)) 171 | } 172 | 173 | func (e *Exporter) isBasicAuthConfigured() bool { 174 | return e.options.BasicAuthUsername != "" && e.options.BasicAuthPassword != "" 175 | } 176 | 177 | func (e *Exporter) verifyBasicAuth(user, password string, authHeaderSet bool) error { 178 | 179 | if !e.isBasicAuthConfigured() { 180 | return nil 181 | } 182 | 183 | if !authHeaderSet { 184 | return errors.New("Unauthorized") 185 | } 186 | 187 | userCorrect := subtle.ConstantTimeCompare([]byte(user), []byte(e.options.BasicAuthUsername)) 188 | passCorrect := subtle.ConstantTimeCompare([]byte(password), []byte(e.options.BasicAuthPassword)) 189 | 190 | if userCorrect == 0 || passCorrect == 0 { 191 | return errors.New("Unauthorized") 192 | } 193 | 194 | return nil 195 | } 196 | -------------------------------------------------------------------------------- /exporter/info_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "fmt" 5 | "net/http/httptest" 6 | "os" 7 | "reflect" 8 | "regexp" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | func TestKeyspaceStringParser(t *testing.T) { 17 | tsts := []struct { 18 | db string 19 | stats string 20 | keysTotal, keysEx, keysCached, avgTTL float64 21 | ok bool 22 | }{ 23 | {db: "xxx", stats: "", ok: false}, 24 | {db: "xxx", stats: "keys=1,expires=0,avg_ttl=0", ok: false}, 25 | {db: "db0", stats: "xxx", ok: false}, 26 | {db: "db1", stats: "keys=abcd,expires=0,avg_ttl=0", ok: false}, 27 | {db: "db2", stats: "keys=1234=1234,expires=0,avg_ttl=0", ok: false}, 28 | 29 | {db: "db3", stats: "keys=abcde,expires=0", ok: false}, 30 | {db: "db3", stats: "keys=213,expires=xxx", ok: false}, 31 | {db: "db3", stats: "keys=123,expires=0,avg_ttl=zzz", ok: false}, 32 | {db: "db3", stats: "keys=1,expires=0,avg_ttl=zzz,cached_keys=0", ok: false}, 33 | {db: "db3", stats: "keys=1,expires=0,avg_ttl=0,cached_keys=zzz", ok: false}, 34 | {db: "db3", stats: "keys=1,expires=0,avg_ttl=0,cached_keys=0,extra=0", ok: false}, 35 | 36 | {db: "db0", stats: "keys=1,expires=0,avg_ttl=0", keysTotal: 1, keysEx: 0, avgTTL: 0, keysCached: -1, ok: true}, 37 | {db: "db0", stats: "keys=1,expires=0,avg_ttl=0,cached_keys=0", keysTotal: 1, keysEx: 0, avgTTL: 0, keysCached: 0, ok: true}, 38 | } 39 | 40 | for _, tst := range tsts { 41 | if kt, kx, ttl, kc, ok := parseDBKeyspaceString(tst.db, tst.stats); true { 42 | 43 | if ok != tst.ok { 44 | t.Errorf("failed for: db:%s stats:%s", tst.db, tst.stats) 45 | continue 46 | } 47 | 48 | if ok && (kt != tst.keysTotal || kx != tst.keysEx || kc != tst.keysCached || ttl != tst.avgTTL) { 49 | t.Errorf("values not matching, db:%s stats:%s %f %f %f %f", tst.db, tst.stats, kt, kx, kc, ttl) 50 | } 51 | } 52 | } 53 | } 54 | 55 | type slaveData struct { 56 | k, v string 57 | ip, state, port string 58 | offset float64 59 | lag float64 60 | ok bool 61 | } 62 | 63 | func TestParseConnectedSlaveString(t *testing.T) { 64 | tsts := []slaveData{ 65 | {k: "slave0", v: "ip=10.254.11.1,port=6379,state=online,offset=1751844676,lag=0", offset: 1751844676, ip: "10.254.11.1", port: "6379", state: "online", ok: true, lag: 0}, 66 | {k: "slave0", v: "ip=2a00:1450:400e:808::200e,port=6379,state=online,offset=1751844676,lag=0", offset: 1751844676, ip: "2a00:1450:400e:808::200e", port: "6379", state: "online", ok: true, lag: 0}, 67 | {k: "slave1", v: "offset=1,lag=0", offset: 1, ok: true}, 68 | {k: "slave1", v: "offset=1", offset: 1, ok: true, lag: -1}, 69 | {k: "slave2", v: "ip=1.2.3.4,state=online,offset=123,lag=42", offset: 123, ip: "1.2.3.4", state: "online", ok: true, lag: 42}, 70 | 71 | {k: "slave", v: "offset=1751844676,lag=0", ok: false}, 72 | {k: "slaveA", v: "offset=1751844676,lag=0", ok: false}, 73 | {k: "slave0", v: "offset=abc,lag=0", ok: false}, 74 | {k: "slave0", v: "offset=0,lag=abc", ok: false}, 75 | } 76 | 77 | for _, tst := range tsts { 78 | t.Run(fmt.Sprintf("%s---%s", tst.k, tst.v), func(t *testing.T) { 79 | offset, ip, port, state, lag, ok := parseConnectedSlaveString(tst.k, tst.v) 80 | 81 | if ok != tst.ok { 82 | t.Errorf("failed for: db:%s stats:%s", tst.k, tst.v) 83 | return 84 | } 85 | if offset != tst.offset || ip != tst.ip || port != tst.port || state != tst.state || lag != tst.lag { 86 | t.Errorf("values not matching, string:%s %f %s %s %s %f", tst.v, offset, ip, port, state, lag) 87 | } 88 | }) 89 | } 90 | } 91 | 92 | func TestCommandStats(t *testing.T) { 93 | defaultAddr := os.Getenv("TEST_REDIS_URI") 94 | e := getTestExporterWithAddr(defaultAddr) 95 | setupTestKeys(t, defaultAddr) 96 | 97 | want := map[string]bool{"test_commands_duration_seconds_total": false, "test_commands_total": false} 98 | commandStatsCheck(t, e, want) 99 | deleteTestKeys(t, defaultAddr) 100 | 101 | redisSixTwoAddr := os.Getenv("TEST_REDIS6_URI") 102 | if redisSixTwoAddr != "" { 103 | // Since Redis v6.2 we should expect extra failed calls and rejected calls 104 | e = getTestExporterWithAddr(redisSixTwoAddr) 105 | setupTestKeys(t, redisSixTwoAddr) 106 | 107 | want = map[string]bool{"test_commands_duration_seconds_total": false, "test_commands_total": false, "commands_failed_calls_total": false, "commands_rejected_calls_total": false, "errors_total": false} 108 | commandStatsCheck(t, e, want) 109 | deleteTestKeys(t, redisSixTwoAddr) 110 | } 111 | } 112 | 113 | func commandStatsCheck(t *testing.T, e *Exporter, want map[string]bool) { 114 | chM := make(chan prometheus.Metric) 115 | go func() { 116 | e.Collect(chM) 117 | close(chM) 118 | }() 119 | 120 | for m := range chM { 121 | for k := range want { 122 | if strings.Contains(m.Desc().String(), k) { 123 | want[k] = true 124 | } 125 | } 126 | } 127 | for k, found := range want { 128 | if !found { 129 | t.Errorf("didn't find %s", k) 130 | } 131 | } 132 | } 133 | 134 | func TestInclMetricsForEmptyDatabases(t *testing.T) { 135 | addr := os.Getenv("TEST_REDIS_URI") 136 | if addr == "" { 137 | t.Skipf("TEST_REDIS_URI not set - skipping") 138 | } 139 | 140 | for _, inclMetrics := range []bool{true, false} { 141 | t.Run(fmt.Sprintf("inclMetrics:%t", inclMetrics), func(t *testing.T) { 142 | e, _ := NewRedisExporter(addr, 143 | Options{ 144 | Namespace: "test", Registry: prometheus.NewRegistry(), 145 | InclMetricsForEmptyDatabases: inclMetrics, 146 | }) 147 | ts := httptest.NewServer(e) 148 | defer ts.Close() 149 | 150 | body := downloadURL(t, ts.URL+"/metrics") 151 | if inclMetrics { 152 | if !strings.Contains(body, `test_db_keys{db="db10"} 0`) { 153 | t.Errorf("Expected to find test_db_keys") 154 | } 155 | } else { 156 | if strings.Contains(body, `test_db_keys{db="db10"} 0`) { 157 | t.Errorf("Expected to not find test_db_keys") 158 | } 159 | } 160 | }) 161 | } 162 | } 163 | 164 | func TestClusterMaster(t *testing.T) { 165 | if os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI") == "" { 166 | t.Skipf("TEST_REDIS_CLUSTER_MASTER_URI not set - skipping") 167 | } 168 | 169 | addr := os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI") 170 | e, _ := NewRedisExporter(addr, Options{Namespace: "test", Registry: prometheus.NewRegistry()}) 171 | ts := httptest.NewServer(e) 172 | defer ts.Close() 173 | 174 | body := downloadURL(t, ts.URL+"/metrics") 175 | log.Debugf("master - body: %s", body) 176 | for _, want := range []string{ 177 | "test_instance_info{", 178 | "test_master_repl_offset", 179 | } { 180 | if !strings.Contains(body, want) { 181 | t.Errorf("Did not find key [%s] \nbody: %s", want, body) 182 | } 183 | } 184 | } 185 | 186 | func TestClusterSkipCheckKeysIfMaster(t *testing.T) { 187 | uriMaster := os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI") 188 | uriSlave := os.Getenv("TEST_REDIS_CLUSTER_SLAVE_URI") 189 | if uriMaster == "" || uriSlave == "" { 190 | t.Skipf("TEST_REDIS_CLUSTER_MASTER_URI or slave not set - skipping") 191 | } 192 | 193 | setupTestKeysCluster(t, uriMaster) 194 | defer deleteTestKeysCluster(t, uriMaster) 195 | 196 | for _, uri := range []string{uriMaster, uriSlave} { 197 | for _, skip := range []bool{true, false} { 198 | e, _ := NewRedisExporter( 199 | uri, 200 | Options{Namespace: "test", 201 | Registry: prometheus.NewRegistry(), 202 | CheckKeys: TestKeyNameHll, 203 | SkipCheckKeysForRoleMaster: skip, 204 | IsCluster: true, 205 | }) 206 | ts := httptest.NewServer(e) 207 | 208 | body := downloadURL(t, ts.URL+"/metrics") 209 | 210 | expectedMetricPresent := true 211 | if skip && uri == uriMaster { 212 | expectedMetricPresent = false 213 | } 214 | t.Logf("skip: %#v uri: %s uri == uriMaster: %#v", skip, uri, uri == uriMaster) 215 | t.Logf("expectedMetricPresent: %#v", expectedMetricPresent) 216 | 217 | want := `test_key_size{db="db0",key="test-hll"} 3` 218 | 219 | if expectedMetricPresent { 220 | if !strings.Contains(body, want) { 221 | t.Fatalf("expectedMetricPresent but missing. metric: %s body: %s\n", want, body) 222 | } 223 | } else { 224 | if strings.Contains(body, want) { 225 | t.Fatalf("should have skipped it but found it, body:\n%s", body) 226 | } 227 | } 228 | 229 | ts.Close() 230 | } 231 | } 232 | } 233 | 234 | func TestClusterSlave(t *testing.T) { 235 | if os.Getenv("TEST_REDIS_CLUSTER_SLAVE_URI") == "" { 236 | t.Skipf("TEST_REDIS_CLUSTER_SLAVE_URI not set - skipping") 237 | } 238 | 239 | addr := os.Getenv("TEST_REDIS_CLUSTER_SLAVE_URI") 240 | e, _ := NewRedisExporter(addr, Options{Namespace: "test", Registry: prometheus.NewRegistry()}) 241 | ts := httptest.NewServer(e) 242 | defer ts.Close() 243 | 244 | body := downloadURL(t, ts.URL+"/metrics") 245 | log.Debugf("slave - body: %s", body) 246 | for _, want := range []string{ 247 | "test_instance_info", 248 | "test_master_last_io_seconds", 249 | "test_slave_info", 250 | } { 251 | if !strings.Contains(body, want) { 252 | t.Errorf("Did not find key [%s] \nbody: %s", want, body) 253 | } 254 | } 255 | hostReg, _ := regexp.Compile(`master_host="([0,1]?\d{1,2}|2([0-4][0-9]|5[0-5]))(\.([0,1]?\d{1,2}|2([0-4][0-9]|5[0-5]))){3}"`) 256 | masterHost := hostReg.FindString(body) 257 | portReg, _ := regexp.Compile(`master_port="(\d+)"`) 258 | masterPort := portReg.FindString(body) 259 | for wantedKey, wantedVal := range map[string]int{ 260 | masterHost: 5, 261 | masterPort: 5, 262 | } { 263 | if res := strings.Count(body, wantedKey); res != wantedVal { 264 | t.Errorf("Result: %s -> %d, Wanted: %d \nbody: %s", wantedKey, res, wantedVal, body) 265 | } 266 | } 267 | } 268 | 269 | func TestParseCommandStats(t *testing.T) { 270 | 271 | for _, tst := range []struct { 272 | fieldKey string 273 | fieldValue string 274 | 275 | wantSuccess bool 276 | wantExtraStats bool 277 | wantCmd string 278 | wantCalls float64 279 | wantRejectedCalls float64 280 | wantFailedCalls float64 281 | wantUsecTotal float64 282 | }{ 283 | { 284 | fieldKey: "cmdstat_get", 285 | fieldValue: "calls=21,usec=175,usec_per_call=8.33", 286 | wantSuccess: true, 287 | wantCmd: "get", 288 | wantCalls: 21, 289 | wantUsecTotal: 175, 290 | }, 291 | { 292 | fieldKey: "cmdstat_georadius_ro", 293 | fieldValue: "calls=75,usec=1260,usec_per_call=16.80", 294 | wantSuccess: true, 295 | wantCmd: "georadius_ro", 296 | wantCalls: 75, 297 | wantUsecTotal: 1260, 298 | }, 299 | { 300 | fieldKey: "borked_stats", 301 | fieldValue: "calls=75,usec=1260,usec_per_call=16.80", 302 | wantSuccess: false, 303 | }, 304 | { 305 | fieldKey: "cmdstat_georadius_ro", 306 | fieldValue: "borked_values", 307 | wantSuccess: false, 308 | }, 309 | 310 | { 311 | fieldKey: "cmdstat_georadius_ro", 312 | fieldValue: "usec_per_call=16.80", 313 | wantSuccess: false, 314 | }, 315 | { 316 | fieldKey: "cmdstat_georadius_ro", 317 | fieldValue: "calls=ABC,usec=1260,usec_per_call=16.80", 318 | wantSuccess: false, 319 | }, 320 | { 321 | fieldKey: "cmdstat_georadius_ro", 322 | fieldValue: "calls=75,usec=DEF,usec_per_call=16.80", 323 | wantSuccess: false, 324 | }, 325 | { 326 | fieldKey: "cmdstat_georadius_ro", 327 | fieldValue: "calls=75,usec=1024,usec_per_call=16.80,rejected_calls=5,failed_calls=10", 328 | wantCmd: "georadius_ro", 329 | wantCalls: 75, 330 | wantUsecTotal: 1024, 331 | wantSuccess: true, 332 | wantExtraStats: true, 333 | wantFailedCalls: 10, 334 | wantRejectedCalls: 5, 335 | }, 336 | { 337 | fieldKey: "cmdstat_georadius_ro", 338 | fieldValue: "calls=75,usec=1024,usec_per_call=16.80,rejected_calls=ABC,failed_calls=10", 339 | wantSuccess: false, 340 | }, 341 | { 342 | fieldKey: "cmdstat_georadius_ro", 343 | fieldValue: "calls=75,usec=1024,usec_per_call=16.80,rejected_calls=5,failed_calls=ABC", 344 | wantSuccess: false, 345 | }, 346 | } { 347 | t.Run(tst.fieldKey+tst.fieldValue, func(t *testing.T) { 348 | 349 | cmd, calls, rejectedCalls, failedCalls, usecTotal, _, err := parseMetricsCommandStats(tst.fieldKey, tst.fieldValue) 350 | 351 | if tst.wantSuccess && err != nil { 352 | t.Fatalf("err: %s", err) 353 | return 354 | } 355 | 356 | if !tst.wantSuccess && err == nil { 357 | t.Fatalf("expected err!") 358 | return 359 | } 360 | 361 | if !tst.wantSuccess { 362 | return 363 | } 364 | 365 | if cmd != tst.wantCmd { 366 | t.Fatalf("cmd not matching, got: %s, wanted: %s", cmd, tst.wantCmd) 367 | } 368 | 369 | if calls != tst.wantCalls { 370 | t.Fatalf("cmd not matching, got: %f, wanted: %f", calls, tst.wantCalls) 371 | } 372 | if rejectedCalls != tst.wantRejectedCalls { 373 | t.Fatalf("cmd not matching, got: %f, wanted: %f", rejectedCalls, tst.wantRejectedCalls) 374 | } 375 | if failedCalls != tst.wantFailedCalls { 376 | t.Fatalf("cmd not matching, got: %f, wanted: %f", failedCalls, tst.wantFailedCalls) 377 | } 378 | if usecTotal != tst.wantUsecTotal { 379 | t.Fatalf("cmd not matching, got: %f, wanted: %f", usecTotal, tst.wantUsecTotal) 380 | } 381 | }) 382 | } 383 | 384 | } 385 | 386 | func TestParseErrorStats(t *testing.T) { 387 | 388 | for _, tst := range []struct { 389 | fieldKey string 390 | fieldValue string 391 | 392 | wantSuccess bool 393 | wantErrorPrefix string 394 | wantCount float64 395 | }{ 396 | { 397 | fieldKey: "errorstat_ERR", 398 | fieldValue: "count=4", 399 | wantSuccess: true, 400 | wantErrorPrefix: "ERR", 401 | wantCount: 4, 402 | }, 403 | { 404 | fieldKey: "borked_stats", 405 | fieldValue: "count=4", 406 | wantSuccess: false, 407 | }, 408 | { 409 | fieldKey: "errorstat_ERR", 410 | fieldValue: "borked_values", 411 | wantSuccess: false, 412 | }, 413 | 414 | { 415 | fieldKey: "errorstat_ERR", 416 | fieldValue: "count=ABC", 417 | wantSuccess: false, 418 | }, 419 | } { 420 | t.Run(tst.fieldKey+tst.fieldValue, func(t *testing.T) { 421 | 422 | errorPrefix, count, err := parseMetricsErrorStats(tst.fieldKey, tst.fieldValue) 423 | 424 | if tst.wantSuccess && err != nil { 425 | t.Fatalf("err: %s", err) 426 | return 427 | } 428 | 429 | if !tst.wantSuccess && err == nil { 430 | t.Fatalf("expected err!") 431 | return 432 | } 433 | 434 | if !tst.wantSuccess { 435 | return 436 | } 437 | 438 | if errorPrefix != tst.wantErrorPrefix { 439 | t.Fatalf("cmd not matching, got: %s, wanted: %s", errorPrefix, tst.wantErrorPrefix) 440 | } 441 | 442 | if count != tst.wantCount { 443 | t.Fatalf("cmd not matching, got: %f, wanted: %f", count, tst.wantCount) 444 | } 445 | }) 446 | } 447 | 448 | } 449 | 450 | func Test_parseMetricsLatencyStats(t *testing.T) { 451 | type args struct { 452 | fieldKey string 453 | fieldValue string 454 | } 455 | tests := []struct { 456 | name string 457 | args args 458 | wantCmd string 459 | wantPercentileMap map[float64]float64 460 | wantErr bool 461 | }{ 462 | { 463 | name: "simple", 464 | args: args{fieldKey: "latency_percentiles_usec_ping", fieldValue: "p50=0.001,p99=1.003,p99.9=3.007"}, 465 | wantCmd: "ping", 466 | wantPercentileMap: map[float64]float64{50.0: 0.001, 99.0: 1.003, 99.9: 3.007}, 467 | wantErr: false, 468 | }, 469 | { 470 | name: "single-percentile", 471 | args: args{fieldKey: "latency_percentiles_usec_ping", fieldValue: "p50=0.001"}, 472 | wantCmd: "ping", 473 | wantPercentileMap: map[float64]float64{50.0: 0.001}, 474 | wantErr: false, 475 | }, 476 | { 477 | name: "empty", 478 | args: args{fieldKey: "latency_percentiles_usec_ping", fieldValue: ""}, 479 | wantCmd: "ping", 480 | wantPercentileMap: map[float64]float64{0: 0}, 481 | wantErr: false, 482 | }, 483 | { 484 | name: "invalid-percentile", 485 | args: args{fieldKey: "latency_percentiles_usec_ping", fieldValue: "p50=a"}, 486 | wantCmd: "ping", 487 | wantPercentileMap: map[float64]float64{}, 488 | wantErr: true, 489 | }, 490 | { 491 | name: "invalid prefix", 492 | args: args{fieldKey: "wrong_prefix_", fieldValue: "p50=0.001,p99=1.003,p99.9=3.007"}, 493 | wantCmd: "", 494 | wantPercentileMap: map[float64]float64{}, 495 | wantErr: true, 496 | }, 497 | } 498 | for _, tt := range tests { 499 | t.Run(tt.name, func(t *testing.T) { 500 | gotCmd, gotPercentileMap, err := parseMetricsLatencyStats(tt.args.fieldKey, tt.args.fieldValue) 501 | if (err != nil) != tt.wantErr { 502 | t.Errorf("test %s. parseMetricsLatencyStats() error = %v, wantErr %v", tt.name, err, tt.wantErr) 503 | return 504 | } 505 | if gotCmd != tt.wantCmd { 506 | t.Errorf("parseMetricsLatencyStats() gotCmd = %v, want %v", gotCmd, tt.wantCmd) 507 | } 508 | if !reflect.DeepEqual(gotPercentileMap, tt.wantPercentileMap) { 509 | t.Errorf("parseMetricsLatencyStats() gotPercentileMap = %v, want %v", gotPercentileMap, tt.wantPercentileMap) 510 | } 511 | }) 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /exporter/key_groups.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/gomodule/redigo/redis" 11 | "github.com/prometheus/client_golang/prometheus" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type keyGroupMetrics struct { 16 | keyGroup string 17 | count int64 18 | memoryUsage int64 19 | } 20 | 21 | type overflowedKeyGroupMetrics struct { 22 | topMemoryUsageKeyGroups []*keyGroupMetrics 23 | overflowKeyGroupAggregate keyGroupMetrics 24 | keyGroupsCount int64 25 | } 26 | 27 | type keyGroupsScrapeResult struct { 28 | duration time.Duration 29 | metrics []map[string]*keyGroupMetrics 30 | overflowedMetrics []*overflowedKeyGroupMetrics 31 | } 32 | 33 | func (e *Exporter) extractKeyGroupMetrics(ch chan<- prometheus.Metric, c redis.Conn, dbCount int) { 34 | allDbKeyGroupMetrics := e.gatherKeyGroupsMetricsForAllDatabases(c, dbCount) 35 | if allDbKeyGroupMetrics == nil { 36 | return 37 | } 38 | for db, dbKeyGroupMetrics := range allDbKeyGroupMetrics.metrics { 39 | dbLabel := fmt.Sprintf("db%d", db) 40 | registerKeyGroupMetrics := func(metrics *keyGroupMetrics) { 41 | e.registerConstMetricGauge( 42 | ch, 43 | "key_group_count", 44 | float64(metrics.count), 45 | dbLabel, 46 | metrics.keyGroup, 47 | ) 48 | e.registerConstMetricGauge( 49 | ch, 50 | "key_group_memory_usage_bytes", 51 | float64(metrics.memoryUsage), 52 | dbLabel, 53 | metrics.keyGroup, 54 | ) 55 | } 56 | if allDbKeyGroupMetrics.overflowedMetrics[db] != nil { 57 | overflowedMetrics := allDbKeyGroupMetrics.overflowedMetrics[db] 58 | for _, metrics := range overflowedMetrics.topMemoryUsageKeyGroups { 59 | registerKeyGroupMetrics(metrics) 60 | } 61 | registerKeyGroupMetrics(&overflowedMetrics.overflowKeyGroupAggregate) 62 | e.registerConstMetricGauge(ch, "number_of_distinct_key_groups", float64(overflowedMetrics.keyGroupsCount), dbLabel) 63 | } else if dbKeyGroupMetrics != nil { 64 | for _, metrics := range dbKeyGroupMetrics { 65 | registerKeyGroupMetrics(metrics) 66 | } 67 | e.registerConstMetricGauge(ch, "number_of_distinct_key_groups", float64(len(dbKeyGroupMetrics)), dbLabel) 68 | } 69 | } 70 | e.registerConstMetricGauge(ch, "last_key_groups_scrape_duration_milliseconds", float64(allDbKeyGroupMetrics.duration.Milliseconds())) 71 | } 72 | 73 | func (e *Exporter) gatherKeyGroupsMetricsForAllDatabases(c redis.Conn, dbCount int) *keyGroupsScrapeResult { 74 | start := time.Now() 75 | allMetrics := &keyGroupsScrapeResult{ 76 | metrics: make([]map[string]*keyGroupMetrics, dbCount), 77 | overflowedMetrics: make([]*overflowedKeyGroupMetrics, dbCount), 78 | } 79 | defer func() { 80 | allMetrics.duration = time.Since(start) 81 | }() 82 | if strings.TrimSpace(e.options.CheckKeyGroups) == "" { 83 | return allMetrics 84 | } 85 | keyGroups, err := csv.NewReader( 86 | strings.NewReader(e.options.CheckKeyGroups), 87 | ).Read() 88 | if err != nil { 89 | log.Errorf("Failed to parse key groups as csv: %s", err) 90 | return allMetrics 91 | } 92 | for i, v := range keyGroups { 93 | keyGroups[i] = strings.TrimSpace(v) 94 | } 95 | 96 | keyGroupsNoEmptyStrings := make([]string, 0) 97 | for _, v := range keyGroups { 98 | if len(v) > 0 { 99 | keyGroupsNoEmptyStrings = append(keyGroupsNoEmptyStrings, v) 100 | } 101 | } 102 | if len(keyGroupsNoEmptyStrings) == 0 { 103 | return allMetrics 104 | } 105 | for db := 0; db < dbCount; db++ { 106 | if _, err := doRedisCmd(c, "SELECT", db); err != nil { 107 | log.Errorf("Couldn't select database %d when getting key info.", db) 108 | continue 109 | } 110 | allGroups, err := gatherKeyGroupMetrics(c, e.options.CheckKeysBatchSize, keyGroupsNoEmptyStrings) 111 | if err != nil { 112 | log.Error(err) 113 | continue 114 | } 115 | allMetrics.metrics[db] = allGroups 116 | if int64(len(allGroups)) > e.options.MaxDistinctKeyGroups { 117 | metricsSlice := make([]*keyGroupMetrics, 0, len(allGroups)) 118 | for _, v := range allGroups { 119 | metricsSlice = append(metricsSlice, v) 120 | } 121 | sort.Slice(metricsSlice, func(i, j int) bool { 122 | if metricsSlice[i].memoryUsage == metricsSlice[j].memoryUsage { 123 | if metricsSlice[i].count == metricsSlice[j].count { 124 | return metricsSlice[i].keyGroup < metricsSlice[j].keyGroup 125 | } 126 | return metricsSlice[i].count < metricsSlice[j].count 127 | } 128 | return metricsSlice[i].memoryUsage > metricsSlice[j].memoryUsage 129 | }) 130 | var overflowedCount, overflowedMemoryUsage int64 131 | for _, v := range metricsSlice[e.options.MaxDistinctKeyGroups:] { 132 | overflowedCount += v.count 133 | overflowedMemoryUsage += v.memoryUsage 134 | } 135 | allMetrics.overflowedMetrics[db] = &overflowedKeyGroupMetrics{ 136 | topMemoryUsageKeyGroups: metricsSlice[:e.options.MaxDistinctKeyGroups], 137 | overflowKeyGroupAggregate: keyGroupMetrics{ 138 | keyGroup: "overflow", 139 | count: overflowedCount, 140 | memoryUsage: overflowedMemoryUsage, 141 | }, 142 | keyGroupsCount: int64(len(allGroups)), 143 | } 144 | } 145 | } 146 | return allMetrics 147 | } 148 | 149 | func gatherKeyGroupMetrics(c redis.Conn, batchSize int64, keyGroups []string) (map[string]*keyGroupMetrics, error) { 150 | allGroups := make(map[string]*keyGroupMetrics) 151 | keysAndArgs := []interface{}{0, batchSize} 152 | for _, keyGroup := range keyGroups { 153 | keysAndArgs = append(keysAndArgs, keyGroup) 154 | } 155 | 156 | script := redis.NewScript( 157 | 0, 158 | ` 159 | local result = {} 160 | local batch = redis.call("SCAN", ARGV[1], "COUNT", ARGV[2]) 161 | local groups = {} 162 | local usage = 0 163 | local group_index = 0 164 | local group = nil 165 | local value = {} 166 | local key_match_result = {} 167 | local status = false 168 | local err = nil 169 | for i=3,#ARGV do 170 | status, err = pcall(string.find, " ", ARGV[i]) 171 | if not status then 172 | error(err .. ARGV[i]) 173 | end 174 | end 175 | for i,key in ipairs(batch[2]) do 176 | local reply = redis.pcall("MEMORY", "USAGE", key) 177 | if type(reply) == "number" then 178 | usage = reply; 179 | end 180 | group = nil 181 | for i=3,#ARGV do 182 | key_match_result = {string.find(key, ARGV[i])} 183 | if key_match_result[1] ~= nil then 184 | group = table.concat({unpack(key_match_result, 3, #key_match_result)}, "") 185 | break 186 | end 187 | end 188 | if group == nil then 189 | group = "unclassified" 190 | end 191 | value = groups[group] 192 | if value == nil then 193 | groups[group] = {1, usage} 194 | else 195 | groups[group] = {value[1] + 1, value[2] + usage} 196 | end 197 | end 198 | for group,value in pairs(groups) do 199 | result[#result+1] = {group, value[1], value[2]} 200 | end 201 | return {batch[1], result}`, 202 | ) 203 | 204 | for { 205 | arr, err := redis.Values(script.Do(c, keysAndArgs...)) 206 | if err != nil { 207 | return nil, err 208 | } 209 | 210 | if len(arr) != 2 { 211 | return nil, fmt.Errorf("invalid response from key group metrics lua script for groups: %s", strings.Join(keyGroups, ", ")) 212 | } 213 | 214 | groups, _ := redis.Values(arr[1], nil) 215 | 216 | for _, group := range groups { 217 | metricsArr, _ := redis.Values(group, nil) 218 | name, _ := redis.String(metricsArr[0], nil) 219 | count, _ := redis.Int64(metricsArr[1], nil) 220 | memoryUsage, _ := redis.Int64(metricsArr[2], nil) 221 | 222 | if currentMetrics, ok := allGroups[name]; ok { 223 | currentMetrics.count += count 224 | currentMetrics.memoryUsage += memoryUsage 225 | } else { 226 | allGroups[name] = &keyGroupMetrics{ 227 | keyGroup: name, 228 | count: count, 229 | memoryUsage: memoryUsage, 230 | } 231 | } 232 | 233 | } 234 | if keysAndArgs[0], _ = redis.Int(arr[0], nil); keysAndArgs[0].(int) == 0 { 235 | break 236 | } 237 | } 238 | return allGroups, nil 239 | } 240 | -------------------------------------------------------------------------------- /exporter/key_groups_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "strconv" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/gomodule/redigo/redis" 12 | "github.com/prometheus/client_golang/prometheus" 13 | dto "github.com/prometheus/client_model/go" 14 | ) 15 | 16 | func getDBCount(c redis.Conn) (dbCount int, err error) { 17 | dbCount = 16 18 | var config []string 19 | if config, err = redis.Strings(doRedisCmd(c, "CONFIG", "GET", "*")); err != nil { 20 | return 21 | } 22 | 23 | for pos := 0; pos < len(config)/2; pos++ { 24 | strKey := config[pos*2] 25 | strVal := config[pos*2+1] 26 | 27 | if strKey == "databases" { 28 | if dbCount, err = strconv.Atoi(strVal); err != nil { 29 | dbCount = 16 30 | } 31 | return 32 | } 33 | } 34 | return 35 | } 36 | 37 | type keyGroupData struct { 38 | name string 39 | checkKeyGroups string 40 | maxDistinctKeyGroups int64 41 | wantedCount map[string]int 42 | wantedMemory map[string]bool 43 | wantedDistintKeyGroups int 44 | } 45 | 46 | func TestKeyGroupMetrics(t *testing.T) { 47 | if os.Getenv("TEST_REDIS_URI") == "" { 48 | t.Skipf("TEST_REDIS_URI not set - skipping") 49 | } 50 | addr := os.Getenv("TEST_REDIS_URI") 51 | c, err := redis.DialURL(addr) 52 | if err != nil { 53 | t.Fatalf("Couldn't connect to %#v: %#v", addr, err) 54 | } 55 | 56 | var dbCount int 57 | if dbCount, err = getDBCount(c); err != nil { 58 | t.Fatalf("Couldn't get dbCount: %#v", err) 59 | } 60 | setupTestKeys(t, addr) 61 | defer deleteTestKeys(t, addr) 62 | 63 | tsts := []keyGroupData{ 64 | { 65 | name: "synchronous with unclassified keys", 66 | checkKeyGroups: "^(key_ringo)_[0-9]+$,^(key_paul)_[0-9]+$,^(key_exp)_.+$", 67 | maxDistinctKeyGroups: 100, 68 | // The actual counts are a function of keys (all types) being set up in the init() function 69 | // and the CheckKeyGroups regexes for initializing the Redis exporter above. The count below 70 | // will need to be updated if either of the aforementioned things have changed. 71 | wantedCount: map[string]int{ 72 | "key_ringo": 1, 73 | "key_paul": 1, 74 | "unclassified": 9, 75 | "key_exp": 5, 76 | }, 77 | wantedMemory: map[string]bool{ 78 | "key_ringo": true, 79 | "key_paul": true, 80 | "unclassified": true, 81 | "key_exp": true, 82 | }, 83 | wantedDistintKeyGroups: 4, 84 | }, 85 | { 86 | name: "synchronous with overflow keys", 87 | checkKeyGroups: "^(.*)$", // Each key is a distinct key group 88 | maxDistinctKeyGroups: 1, 89 | // The actual counts depend on the largest key being set up in the init() 90 | // function (test-stream at the time this code was written) and the total 91 | // of keys (all types). This will need to be updated to match future 92 | // updates of the init() function 93 | wantedCount: map[string]int{ 94 | "overflow": 15, "test-stream": 1, 95 | }, 96 | wantedMemory: map[string]bool{ 97 | "overflow": true, "test-stream": true, 98 | }, 99 | wantedDistintKeyGroups: 16, 100 | }, 101 | } 102 | 103 | for _, tst := range tsts { 104 | t.Run(tst.name, func(t *testing.T) { 105 | e, _ := NewRedisExporter( 106 | addr, 107 | Options{ 108 | Namespace: "test", 109 | CheckKeyGroups: tst.checkKeyGroups, 110 | CheckKeysBatchSize: 1000, 111 | MaxDistinctKeyGroups: tst.maxDistinctKeyGroups, 112 | }, 113 | ) 114 | for { 115 | chM := make(chan prometheus.Metric) 116 | go func() { 117 | e.extractKeyGroupMetrics(chM, c, dbCount) 118 | close(chM) 119 | }() 120 | 121 | actualCount := make(map[string]int) 122 | actualMemory := make(map[string]bool) 123 | actualDistinctKeyGroups := 0 124 | 125 | receivedMetrics := false 126 | for m := range chM { 127 | receivedMetrics = true 128 | got := &dto.Metric{} 129 | m.Write(got) 130 | 131 | if strings.Contains(m.Desc().String(), "test_key_group_count") { 132 | for _, label := range got.GetLabel() { 133 | if *label.Name == "key_group" { 134 | actualCount[*label.Value] = int(*got.Gauge.Value) 135 | } 136 | } 137 | } else if strings.Contains(m.Desc().String(), "test_key_group_memory_usage_bytes") { 138 | for _, label := range got.GetLabel() { 139 | if *label.Name == "key_group" { 140 | actualMemory[*label.Value] = true 141 | } 142 | } 143 | } else if strings.Contains(m.Desc().String(), "test_number_of_distinct_key_groups") { 144 | for _, label := range got.GetLabel() { 145 | if *label.Name == "db" && *label.Value == "db"+dbNumStr { 146 | actualDistinctKeyGroups = int(*got.Gauge.Value) 147 | } 148 | } 149 | } 150 | } 151 | 152 | if !receivedMetrics { 153 | time.Sleep(100 * time.Millisecond) 154 | continue 155 | } 156 | if !reflect.DeepEqual(tst.wantedCount, actualCount) { 157 | t.Errorf("Key group count metrics are not expected:\n Expected: %#v\nActual: %#v\n", tst.wantedCount, actualCount) 158 | } 159 | 160 | // It's a little fragile to anticipate how much memory 161 | // will be allocated for specific key groups, so we 162 | // are only going to check for presence of memory usage 163 | // metrics for expected key groups here. 164 | if !reflect.DeepEqual(tst.wantedMemory, actualMemory) { 165 | t.Errorf("Key group memory usage metrics are not expected:\n Expected: %#v\nActual: %#v\n", tst.wantedMemory, actualMemory) 166 | } 167 | 168 | if actualDistinctKeyGroups != tst.wantedDistintKeyGroups { 169 | t.Errorf("Unexpected number of distinct key groups, expected: %d, actual: %d", tst.wantedDistintKeyGroups, actualDistinctKeyGroups) 170 | } 171 | break 172 | } 173 | }) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /exporter/keys.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/gomodule/redigo/redis" 11 | "github.com/prometheus/client_golang/prometheus" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | type dbKeyPair struct { 16 | db string 17 | key string 18 | } 19 | 20 | func getStringInfoNotPipelined(c redis.Conn, key string) (strVal string, keyType string, size int64, err error) { 21 | if strVal, err = redis.String(doRedisCmd(c, "GET", key)); err != nil { 22 | log.Errorf("GET %s err: %s", key, err) 23 | } 24 | 25 | // Check PFCOUNT first because STRLEN on HyperLogLog strings returns the wrong length 26 | // while PFCOUNT only works on HLL strings and returns an error on regular strings. 27 | // 28 | // no pipelining / batching for cluster mode, it's not supported 29 | if size, err = redis.Int64(doRedisCmd(c, "PFCOUNT", key)); err == nil { 30 | // hyperloglog 31 | keyType = "HLL" 32 | return 33 | } else if size, err = redis.Int64(doRedisCmd(c, "STRLEN", key)); err == nil { 34 | keyType = "string" 35 | return 36 | } 37 | return 38 | } 39 | 40 | func (e *Exporter) getKeyInfo(ch chan<- prometheus.Metric, c redis.Conn, dbLabel string, keyType string, keyName string) { 41 | var err error 42 | var size int64 43 | var strVal string 44 | 45 | switch keyType { 46 | case "none": 47 | log.Debugf("Key '%s' not found when trying to get type and size: using default '0.0'", keyName) 48 | e.registerConstMetricGauge(ch, "key_size", 0.0, dbLabel, keyName) 49 | return 50 | 51 | case "string": 52 | strVal, keyType, size, err = getStringInfoNotPipelined(c, keyName) 53 | case "list": 54 | size, err = redis.Int64(doRedisCmd(c, "LLEN", keyName)) 55 | case "set": 56 | size, err = redis.Int64(doRedisCmd(c, "SCARD", keyName)) 57 | case "zset": 58 | size, err = redis.Int64(doRedisCmd(c, "ZCARD", keyName)) 59 | case "hash": 60 | size, err = redis.Int64(doRedisCmd(c, "HLEN", keyName)) 61 | case "stream": 62 | size, err = redis.Int64(doRedisCmd(c, "XLEN", keyName)) 63 | default: 64 | err = fmt.Errorf("unknown type: %v for key: %v", keyType, keyName) 65 | } 66 | 67 | if err != nil { 68 | log.Errorf("getKeyInfo() err: %s", err) 69 | return 70 | } 71 | 72 | e.registerConstMetricGauge(ch, "key_size", float64(size), dbLabel, keyName) 73 | 74 | // Only run on single value strings 75 | if keyType == "string" && !e.options.DisableExportingKeyValues && strVal != "" { 76 | if val, err := strconv.ParseFloat(strVal, 64); err == nil { 77 | // Only record value metric if value is float-y 78 | e.registerConstMetricGauge(ch, "key_value", val, dbLabel, keyName) 79 | } else { 80 | // if it's not float-y then we'll record the value as a string label 81 | e.registerConstMetricGauge(ch, "key_value_as_string", 1.0, dbLabel, keyName, strVal) 82 | } 83 | } 84 | } 85 | 86 | func (e *Exporter) extractCheckKeyMetrics(ch chan<- prometheus.Metric, redisClient redis.Conn) error { 87 | c := redisClient 88 | 89 | if e.options.IsCluster { 90 | cc, err := e.connectToRedisCluster() 91 | if err != nil { 92 | return fmt.Errorf("couldn't connect to redis cluster, err: %s", err) 93 | } 94 | defer cc.Close() 95 | 96 | c = cc 97 | } 98 | 99 | keys, err := parseKeyArg(e.options.CheckKeys) 100 | if err != nil { 101 | return fmt.Errorf("couldn't parse check-keys: %w", err) 102 | } 103 | log.Debugf("keys: %#v", keys) 104 | 105 | singleKeys, err := parseKeyArg(e.options.CheckSingleKeys) 106 | if err != nil { 107 | return fmt.Errorf("couldn't parse check-single-keys: %w", err) 108 | } 109 | log.Debugf("e.singleKeys: %#v", singleKeys) 110 | 111 | allKeys := append([]dbKeyPair{}, singleKeys...) 112 | 113 | log.Debugf("e.keys: %#v", keys) 114 | 115 | if scannedKeys, err := getKeysFromPatterns(c, keys, e.options.CheckKeysBatchSize); err == nil { 116 | allKeys = append(allKeys, scannedKeys...) 117 | } else { 118 | log.Errorf("Error expanding key patterns: %#v", err) 119 | } 120 | 121 | log.Debugf("allKeys: %#v", allKeys) 122 | 123 | /* 124 | important: when adding, modifying, removing metrics both paths here 125 | (pipelined/non-pipelined) need to be modified 126 | */ 127 | if e.options.IsCluster { 128 | e.extractCheckKeyMetricsNotPipelined(ch, c, allKeys) 129 | } else { 130 | e.extractCheckKeyMetricsPipelined(ch, c, allKeys) 131 | } 132 | return nil 133 | } 134 | 135 | func (e *Exporter) extractCheckKeyMetricsPipelined(ch chan<- prometheus.Metric, c redis.Conn, allKeys []dbKeyPair) { 136 | // 137 | // the following commands are all pipelined/batched to improve performance 138 | // by removing one roundtrip to the redis instance 139 | // see https://github.com/oliver006/redis_exporter/issues/980 140 | // 141 | 142 | /* 143 | group keys by DB so we don't have to do repeated SELECT calls and jump between DBs 144 | --> saves roundtrips, improves latency 145 | */ 146 | keysByDb := map[string][]string{} 147 | for _, k := range allKeys { 148 | if a, ok := keysByDb[k.db]; ok { 149 | // exists already 150 | a = append(a, k.key) 151 | keysByDb[k.db] = a 152 | } else { 153 | // first time - got to init the array 154 | keysByDb[k.db] = []string{k.key} 155 | } 156 | } 157 | 158 | for dbNum, arrayOfKeys := range keysByDb { 159 | dbLabel := "db" + dbNum 160 | 161 | log.Debugf("c.Send() SELECT [%s]", dbNum) 162 | if err := c.Send("SELECT", dbNum); err != nil { 163 | log.Errorf("Couldn't select database [%s] when getting key info.", dbNum) 164 | continue 165 | } 166 | /* 167 | first pipeline (batch) all the TYPE & MEMORY USAGE calls and ship them to the redis instance 168 | everything else is dependent on the TYPE of the key 169 | */ 170 | 171 | for _, keyName := range arrayOfKeys { 172 | log.Debugf("c.Send() TYPE [%v]", keyName) 173 | if err := c.Send("TYPE", keyName); err != nil { 174 | log.Errorf("c.Send() TYPE err: %s", err) 175 | return 176 | } 177 | log.Debugf("c.Send() MEMORY USAGE [%v]", keyName) 178 | if err := c.Send("MEMORY", "USAGE", keyName); err != nil { 179 | log.Errorf("c.Send() MEMORY USAGE err: %s", err) 180 | return 181 | } 182 | } 183 | 184 | log.Debugf("c.Flush()") 185 | if err := c.Flush(); err != nil { 186 | log.Errorf("FLUSH err: %s", err) 187 | return 188 | } 189 | 190 | // throwaway Receive() call for the response of the SELECT() call 191 | if _, err := redis.String(c.Receive()); err != nil { 192 | log.Errorf("Receive() err: %s", err) 193 | continue 194 | } 195 | 196 | /* 197 | populate "keyTypes" with the batched TYPE responses from the redis instance 198 | and collect MEMORY USAGE responses and immediately emmit that metric 199 | */ 200 | keyTypes := make([]string, len(arrayOfKeys)) 201 | for idx, keyName := range arrayOfKeys { 202 | var err error 203 | keyTypes[idx], err = redis.String(c.Receive()) 204 | if err != nil { 205 | log.Errorf("key: [%s] - Receive err: %s", keyName, err) 206 | continue 207 | } 208 | memUsageInBytes, err := redis.Int64(c.Receive()) 209 | if err != nil { 210 | // log.Errorf("key: [%s] - memUsageInBytes Receive() err: %s", keyName, err) 211 | continue 212 | } 213 | 214 | e.registerConstMetricGauge(ch, 215 | "key_memory_usage_bytes", 216 | float64(memUsageInBytes), 217 | dbLabel, 218 | keyName) 219 | } 220 | 221 | /* 222 | now that we have the types for all the keys we can gather information about 223 | each key like size & length and value (redis cmd used is dependent on TYPE) 224 | */ 225 | e.getKeyInfoPipelined(ch, c, dbLabel, arrayOfKeys, keyTypes) 226 | } 227 | } 228 | 229 | func (e *Exporter) getKeyInfoPipelined(ch chan<- prometheus.Metric, c redis.Conn, dbLabel string, arrayOfKeys []string, keyTypes []string) { 230 | for idx, keyName := range arrayOfKeys { 231 | keyType := keyTypes[idx] 232 | switch keyType { 233 | case "none": 234 | continue 235 | 236 | case "string": 237 | log.Debugf("c.Send() PFCOUNT args: [%v]", keyName) 238 | if err := c.Send("PFCOUNT", keyName); err != nil { 239 | log.Errorf("PFCOUNT err: %s", err) 240 | return 241 | } 242 | 243 | log.Debugf("c.Send() STRLEN args: [%v]", keyName) 244 | if err := c.Send("STRLEN", keyName); err != nil { 245 | log.Errorf("STRLEN err: %s", err) 246 | return 247 | } 248 | 249 | log.Debugf("c.Send() GET args: [%v]", keyName) 250 | if err := c.Send("GET", keyName); err != nil { 251 | log.Errorf("GET err: %s", err) 252 | return 253 | } 254 | 255 | case "list": 256 | log.Debugf("c.Send() LLEN args: [%v]", keyName) 257 | if err := c.Send("LLEN", keyName); err != nil { 258 | log.Errorf("LLEN err: %s", err) 259 | return 260 | } 261 | 262 | case "set": 263 | log.Debugf("c.Send() SCARD args: [%v]", keyName) 264 | if err := c.Send("SCARD", keyName); err != nil { 265 | log.Errorf("SCARD err: %s", err) 266 | return 267 | } 268 | case "zset": 269 | log.Debugf("c.Send() ZCARD args: [%v]", keyName) 270 | if err := c.Send("ZCARD", keyName); err != nil { 271 | log.Errorf("ZCARD err: %s", err) 272 | return 273 | } 274 | 275 | case "hash": 276 | log.Debugf("c.Send() HLEN args: [%v]", keyName) 277 | if err := c.Send("HLEN", keyName); err != nil { 278 | log.Errorf("HLEN err: %s", err) 279 | return 280 | } 281 | 282 | case "stream": 283 | log.Debugf("c.Send() XLEN args: [%v]", keyName) 284 | if err := c.Send("XLEN", keyName); err != nil { 285 | log.Errorf("XLEN err: %s", err) 286 | return 287 | } 288 | default: 289 | log.Errorf("unknown type: %v for key: %v", keyType, keyName) 290 | continue 291 | } 292 | } 293 | 294 | log.Debugf("c.Flush()") 295 | if err := c.Flush(); err != nil { 296 | log.Errorf("Flush() err: %s", err) 297 | return 298 | } 299 | 300 | for idx, keyName := range arrayOfKeys { 301 | keyType := keyTypes[idx] 302 | 303 | var err error 304 | var size int64 305 | var strVal string 306 | 307 | switch keyType { 308 | case "none": 309 | log.Debugf("Key '%s' not found, skipping", keyName) 310 | 311 | case "string": 312 | hllSize, hllErr := redis.Int64(c.Receive()) 313 | strSize, strErr := redis.Int64(c.Receive()) 314 | 315 | var strValErr error 316 | if strVal, strValErr = redis.String(c.Receive()); strValErr != nil { 317 | log.Errorf("c.Receive() for GET %s err: %s", keyName, strValErr) 318 | } 319 | 320 | log.Debugf("Done with c.Receive() x 3") 321 | 322 | if hllErr == nil { 323 | // hyperloglog 324 | size = hllSize 325 | 326 | // "TYPE" reports hll as string 327 | // this will prevent treating the result as a string by the caller (e.g. call GET) 328 | keyType = "HLL" 329 | } else if strErr == nil { 330 | // not hll so possibly a string? 331 | size = strSize 332 | keyType = "string" 333 | } else { 334 | continue 335 | } 336 | 337 | case "hash", "list", "set", "stream", "zset": 338 | size, err = redis.Int64(c.Receive()) 339 | default: 340 | err = fmt.Errorf("unknown type: %v for key: %v", keyType, keyName) 341 | } 342 | 343 | if err != nil { 344 | log.Errorf("getKeyInfo() err: %s", err) 345 | continue 346 | } 347 | 348 | if keyType == "string" && !e.options.DisableExportingKeyValues && strVal != "" { 349 | if val, err := strconv.ParseFloat(strVal, 64); err == nil { 350 | // Only record value metric if value is float-y 351 | e.registerConstMetricGauge(ch, "key_value", val, dbLabel, keyName) 352 | } else { 353 | // if it's not float-y then we'll record the value as a string label 354 | e.registerConstMetricGauge(ch, "key_value_as_string", 1.0, dbLabel, keyName, strVal) 355 | } 356 | } 357 | 358 | e.registerConstMetricGauge(ch, "key_size", float64(size), dbLabel, keyName) 359 | } 360 | } 361 | 362 | func (e *Exporter) extractCheckKeyMetricsNotPipelined(ch chan<- prometheus.Metric, c redis.Conn, allKeys []dbKeyPair) { 363 | // Cluster mode only has one db 364 | // no need to run `SELECT" but got to set it to "0" in the loop because it's used as the label 365 | for _, k := range allKeys { 366 | k.db = "0" 367 | 368 | keyType, err := redis.String(doRedisCmd(c, "TYPE", k.key)) 369 | if err != nil { 370 | log.Errorf("TYPE err: %s", keyType) 371 | continue 372 | } 373 | 374 | if memUsageInBytes, err := redis.Int64(doRedisCmd(c, "MEMORY", "USAGE", k.key)); err == nil { 375 | e.registerConstMetricGauge(ch, "key_memory_usage_bytes", float64(memUsageInBytes), "db"+k.db, k.key) 376 | } else { 377 | log.Errorf("MEMORY USAGE %s err: %s", k.key, err) 378 | } 379 | 380 | dbLabel := "db" + k.db 381 | e.getKeyInfo(ch, c, dbLabel, keyType, k.key) 382 | } 383 | } 384 | 385 | func (e *Exporter) extractCountKeysMetrics(ch chan<- prometheus.Metric, c redis.Conn) { 386 | cntKeys, err := parseKeyArg(e.options.CountKeys) 387 | if err != nil { 388 | log.Errorf("Couldn't parse given count keys: %s", err) 389 | return 390 | } 391 | 392 | for _, k := range cntKeys { 393 | if _, err := doRedisCmd(c, "SELECT", k.db); err != nil { 394 | log.Errorf("Couldn't select database '%s' when getting stream info", k.db) 395 | continue 396 | } 397 | cnt, err := getKeysCount(c, k.key, e.options.CheckKeysBatchSize) 398 | if err != nil { 399 | log.Errorf("couldn't get key count for '%s', err: %s", k.key, err) 400 | continue 401 | } 402 | dbLabel := "db" + k.db 403 | e.registerConstMetricGauge(ch, "keys_count", float64(cnt), dbLabel, k.key) 404 | } 405 | } 406 | 407 | func getKeysCount(c redis.Conn, pattern string, count int64) (int, error) { 408 | keysCount := 0 409 | 410 | keys, err := scanKeys(c, pattern, count) 411 | if err != nil { 412 | return keysCount, fmt.Errorf("error retrieving '%s' keys err: %s", pattern, err) 413 | } 414 | keysCount = len(keys) 415 | 416 | return keysCount, nil 417 | } 418 | 419 | // Regexp pattern to check if given key contains any 420 | // glob-style pattern symbol. 421 | // 422 | // https://redis.io/commands/scan#the-match-option 423 | var globPattern = regexp.MustCompile(`[\?\*\[\]\^]+`) 424 | 425 | // getKeysFromPatterns does a SCAN for a key if the key contains pattern characters 426 | func getKeysFromPatterns(c redis.Conn, keys []dbKeyPair, count int64) (expandedKeys []dbKeyPair, err error) { 427 | expandedKeys = []dbKeyPair{} 428 | for _, k := range keys { 429 | if globPattern.MatchString(k.key) { 430 | if _, err := doRedisCmd(c, "SELECT", k.db); err != nil { 431 | return expandedKeys, err 432 | } 433 | keyNames, err := redis.Strings(scanKeys(c, k.key, count)) 434 | if err != nil { 435 | log.Errorf("error with SCAN for pattern: %#v err: %s", k.key, err) 436 | continue 437 | } 438 | 439 | for _, keyName := range keyNames { 440 | expandedKeys = append(expandedKeys, dbKeyPair{db: k.db, key: keyName}) 441 | } 442 | } else { 443 | expandedKeys = append(expandedKeys, k) 444 | } 445 | } 446 | 447 | return expandedKeys, err 448 | } 449 | 450 | // parseKeyArgs splits a command-line supplied argument into a slice of dbKeyPairs. 451 | func parseKeyArg(keysArgString string) (keys []dbKeyPair, err error) { 452 | if keysArgString == "" { 453 | log.Debugf("parseKeyArg(): Got empty key arguments, parsing skipped") 454 | return keys, err 455 | } 456 | for _, k := range strings.Split(keysArgString, ",") { 457 | var db string 458 | var key string 459 | if k == "" { 460 | continue 461 | } 462 | frags := strings.Split(k, "=") 463 | switch len(frags) { 464 | case 1: 465 | db = "0" 466 | key, err = url.QueryUnescape(strings.TrimSpace(frags[0])) 467 | case 2: 468 | db = strings.ReplaceAll(strings.TrimSpace(frags[0]), "db", "") 469 | key, err = url.QueryUnescape(strings.TrimSpace(frags[1])) 470 | default: 471 | return keys, fmt.Errorf("invalid key list argument: %s", k) 472 | } 473 | if err != nil { 474 | return keys, fmt.Errorf("couldn't parse db/key string: %s", k) 475 | } 476 | 477 | // We want to guarantee at the top level that invalid values 478 | // will not fall into the final Redis call. 479 | if db == "" || key == "" { 480 | log.Errorf("parseKeyArg(): Empty value parsed in pair '%s=%s', skip", db, key) 481 | continue 482 | } 483 | 484 | number, err := strconv.Atoi(db) 485 | if err != nil || number < 0 { 486 | return keys, fmt.Errorf("invalid database index for db \"%s\": %s", db, err) 487 | } 488 | 489 | keys = append(keys, dbKeyPair{db, key}) 490 | } 491 | return keys, err 492 | } 493 | 494 | // scanForKeys returns a list of keys matching `pattern` by using `SCAN`, which is safer for production systems than using `KEYS`. 495 | // This function was adapted from: https://github.com/reisinger/examples-redigo 496 | func scanKeys(c redis.Conn, pattern string, count int64) (keys []interface{}, err error) { 497 | if pattern == "" { 498 | return keys, fmt.Errorf("pattern shouldn't be empty") 499 | } 500 | 501 | iter := 0 502 | for { 503 | arr, err := redis.Values(doRedisCmd(c, "SCAN", iter, "MATCH", pattern, "COUNT", count)) 504 | if err != nil { 505 | return keys, fmt.Errorf("error retrieving '%s' keys err: %s", pattern, err) 506 | } 507 | if len(arr) != 2 { 508 | return keys, fmt.Errorf("invalid response from SCAN for pattern: %s", pattern) 509 | } 510 | 511 | k, _ := redis.Values(arr[1], nil) 512 | keys = append(keys, k...) 513 | 514 | if iter, _ = redis.Int(arr[0], nil); iter == 0 { 515 | break 516 | } 517 | } 518 | 519 | return keys, nil 520 | } 521 | -------------------------------------------------------------------------------- /exporter/latency.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | 8 | "sync" 9 | 10 | "github.com/gomodule/redigo/redis" 11 | "github.com/prometheus/client_golang/prometheus" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | var ( 16 | logLatestErrOnce, logHistogramErrOnce sync.Once 17 | 18 | extractUsecRegexp = regexp.MustCompile(`(?m)^cmdstat_([a-zA-Z0-9\|]+):.*usec=([0-9]+).*$`) 19 | ) 20 | 21 | func (e *Exporter) extractLatencyMetrics(ch chan<- prometheus.Metric, infoAll string, c redis.Conn) { 22 | e.extractLatencyLatestMetrics(ch, c) 23 | e.extractLatencyHistogramMetrics(ch, infoAll, c) 24 | } 25 | 26 | func (e *Exporter) extractLatencyLatestMetrics(outChan chan<- prometheus.Metric, redisConn redis.Conn) { 27 | reply, err := redis.Values(doRedisCmd(redisConn, "LATENCY", "LATEST")) 28 | if err != nil { 29 | /* 30 | this can be a little too verbose, see e.g. https://github.com/oliver006/redis_exporter/issues/495 31 | we're logging this only once as an Error and always as Debugf() 32 | */ 33 | logLatestErrOnce.Do(func() { 34 | log.Errorf("WARNING, LOGGED ONCE ONLY: cmd LATENCY LATEST, err: %s", err) 35 | }) 36 | log.Debugf("cmd LATENCY LATEST, err: %s", err) 37 | return 38 | } 39 | 40 | for _, l := range reply { 41 | if latencyResult, err := redis.Values(l, nil); err == nil { 42 | var eventName string 43 | var spikeLast, spikeDuration, maxLatency int64 44 | if _, err := redis.Scan(latencyResult, &eventName, &spikeLast, &spikeDuration, &maxLatency); err == nil { 45 | spikeDurationSeconds := float64(spikeDuration) / 1e3 46 | e.registerConstMetricGauge(outChan, "latency_spike_last", float64(spikeLast), eventName) 47 | e.registerConstMetricGauge(outChan, "latency_spike_duration_seconds", spikeDurationSeconds, eventName) 48 | } 49 | } 50 | } 51 | } 52 | 53 | /* 54 | https://redis.io/docs/latest/commands/latency-histogram/ 55 | */ 56 | func (e *Exporter) extractLatencyHistogramMetrics(outChan chan<- prometheus.Metric, infoAll string, redisConn redis.Conn) { 57 | reply, err := redis.Values(doRedisCmd(redisConn, "LATENCY", "HISTOGRAM")) 58 | if err != nil { 59 | logHistogramErrOnce.Do(func() { 60 | log.Errorf("WARNING, LOGGED ONCE ONLY: cmd LATENCY HISTOGRAM, err: %s", err) 61 | }) 62 | log.Debugf("cmd LATENCY HISTOGRAM, err: %s", err) 63 | return 64 | } 65 | 66 | for i := 0; i < len(reply); i += 2 { 67 | cmd, _ := redis.String(reply[i], nil) 68 | details, _ := redis.Values(reply[i+1], nil) 69 | 70 | var totalCalls uint64 71 | var bucketInfo []uint64 72 | 73 | if _, err := redis.Scan(details, nil, &totalCalls, nil, &bucketInfo); err != nil { 74 | break 75 | } 76 | 77 | buckets := map[float64]uint64{} 78 | 79 | for j := 0; j < len(bucketInfo); j += 2 { 80 | usec := float64(bucketInfo[j]) 81 | count := bucketInfo[j+1] 82 | buckets[usec] = count 83 | } 84 | 85 | totalUsecs := extractTotalUsecForCommand(infoAll, cmd) 86 | 87 | e.createMetricDescription("commands_latencies_usec", []string{"cmd"}) 88 | e.registerConstHistogram(outChan, "commands_latencies_usec", totalCalls, float64(totalUsecs), buckets, cmd) 89 | } 90 | } 91 | 92 | func extractTotalUsecForCommand(infoAll string, cmd string) uint64 { 93 | total := uint64(0) 94 | 95 | matches := extractUsecRegexp.FindAllStringSubmatch(infoAll, -1) 96 | for _, match := range matches { 97 | if !strings.HasPrefix(match[1], cmd) { 98 | continue 99 | } 100 | 101 | usecs, err := strconv.ParseUint(match[2], 10, 0) 102 | if err != nil { 103 | log.Warnf("Unable to parse uint from string \"%s\": %v", match[2], err) 104 | continue 105 | } 106 | 107 | total += usecs 108 | } 109 | 110 | return total 111 | } 112 | -------------------------------------------------------------------------------- /exporter/latency_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "os" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/gomodule/redigo/redis" 12 | "github.com/prometheus/client_golang/prometheus" 13 | dto "github.com/prometheus/client_model/go" 14 | ) 15 | 16 | const ( 17 | latencyTestTimeToSleepInMillis = 200 18 | ) 19 | 20 | func TestLatencySpike(t *testing.T) { 21 | e := getTestExporter() 22 | 23 | setupLatency(t, os.Getenv("TEST_REDIS_URI")) 24 | defer resetLatency(t, os.Getenv("TEST_REDIS_URI")) 25 | 26 | chM := make(chan prometheus.Metric) 27 | go func() { 28 | e.Collect(chM) 29 | close(chM) 30 | }() 31 | 32 | for m := range chM { 33 | if strings.Contains(m.Desc().String(), "latency_spike_duration_seconds") { 34 | got := &dto.Metric{} 35 | m.Write(got) 36 | 37 | // The metric value is in seconds, but our sleep interval is specified 38 | // in milliseconds, so we need to convert 39 | val := got.GetGauge().GetValue() * 1000 40 | // Because we're dealing with latency, there might be a slight delay 41 | // even after sleeping for a specific amount of time so checking 42 | // to see if we're between +-5 of our expected value 43 | if math.Abs(float64(latencyTestTimeToSleepInMillis)-val) > 5 { 44 | t.Errorf("values not matching, %f != %f", float64(latencyTestTimeToSleepInMillis), val) 45 | } 46 | } 47 | } 48 | 49 | resetLatency(t, os.Getenv("TEST_REDIS_URI")) 50 | 51 | chM = make(chan prometheus.Metric) 52 | go func() { 53 | e.Collect(chM) 54 | close(chM) 55 | }() 56 | 57 | for m := range chM { 58 | switch m := m.(type) { 59 | case prometheus.Gauge: 60 | if strings.Contains(m.Desc().String(), "latency_spike_duration_seconds") { 61 | t.Errorf("latency threshold was not reset") 62 | } 63 | } 64 | } 65 | } 66 | 67 | func setupLatency(t *testing.T, addr string) error { 68 | c, err := redis.DialURL(addr) 69 | if err != nil { 70 | t.Errorf("couldn't setup redis, err: %s ", err) 71 | return err 72 | } 73 | defer c.Close() 74 | 75 | _, err = c.Do("CONFIG", "SET", "LATENCY-MONITOR-THRESHOLD", 100) 76 | if err != nil { 77 | t.Errorf("couldn't setup redis, err: %s ", err) 78 | return err 79 | } 80 | 81 | // Have to pass in the sleep time in seconds so we have to divide 82 | // the number of milliseconds by 1000 to get number of seconds 83 | _, err = c.Do("DEBUG", "SLEEP", latencyTestTimeToSleepInMillis/1000.0) 84 | if err != nil { 85 | t.Errorf("couldn't setup redis, err: %s ", err) 86 | return err 87 | } 88 | 89 | time.Sleep(time.Millisecond * 50) 90 | 91 | return nil 92 | } 93 | 94 | func resetLatency(t *testing.T, addr string) error { 95 | c, err := redis.DialURL(addr) 96 | if err != nil { 97 | t.Errorf("couldn't setup redis, err: %s ", err) 98 | return err 99 | } 100 | defer c.Close() 101 | 102 | _, err = c.Do("LATENCY", "RESET") 103 | if err != nil { 104 | t.Errorf("couldn't setup redis, err: %s ", err) 105 | return err 106 | } 107 | 108 | time.Sleep(time.Millisecond * 50) 109 | 110 | return nil 111 | } 112 | 113 | func TestLatencyHistogram(t *testing.T) { 114 | addr := os.Getenv("TEST_REDIS_URI") 115 | 116 | // Since Redis 7.0.0 we should have latency histogram stats 117 | e := getTestExporterWithAddr(addr) 118 | setupTestKeys(t, addr) 119 | 120 | want := map[string]bool{"commands_latencies_usec": false} 121 | commandStatsCheck(t, e, want) 122 | deleteTestKeys(t, addr) 123 | } 124 | 125 | func TestExtractTotalUsecForCommand(t *testing.T) { 126 | statsOutString := `# Commandstats 127 | cmdstat_testerr|1:calls=1,usec_per_call=5.00,rejected_calls=0,failed_calls=0 128 | cmdstat_testerr:calls=1,usec=2,usec_per_call=5.00,rejected_calls=0,failed_calls=0 129 | cmdstat_testerr2:calls=1,usec=-2,usec_per_call=5.00,rejected_calls=0,failed_calls=0 130 | cmdstat_testerr3:calls=1,usec=` + fmt.Sprintf("%d1", uint64(math.MaxUint64)) + `,usec_per_call=5.00,rejected_calls=0,failed_calls=0 131 | cmdstat_config|get:calls=69103,usec=15005068,usec_per_call=217.14,rejected_calls=0,failed_calls=0 132 | cmdstat_config|set:calls=3,usec=58,usec_per_call=19.33,rejected_calls=0,failed_calls=3 133 | 134 | # Latencystats 135 | latency_percentiles_usec_pubsub|channels:p50=5.023,p99=5.023,p99.9=5.023 136 | latency_percentiles_usec_config|get:p50=272.383,p99=346.111,p99.9=395.263 137 | latency_percentiles_usec_config|set:p50=23.039,p99=27.007,p99.9=27.007` 138 | 139 | testMap := map[string]uint64{ 140 | "config|set": 58, 141 | "config": 58 + 15005068, 142 | "testerr|1": 0, 143 | "testerr": 2 + 0, 144 | "testerr2": 0, 145 | "testerr3": 0, 146 | } 147 | 148 | for cmd, expected := range testMap { 149 | if res := extractTotalUsecForCommand(statsOutString, cmd); res != expected { 150 | t.Errorf("Incorrect usec extracted. Expected %d but got %d!", expected, res) 151 | } 152 | } 153 | } 154 | 155 | func TestLatencyStats(t *testing.T) { 156 | redisSevenAddr := os.Getenv("TEST_REDIS_URI") 157 | 158 | // Since Redis v7 we should have extended latency stats (summary of command latencies) 159 | e := getTestExporterWithAddr(redisSevenAddr) 160 | setupTestKeys(t, redisSevenAddr) 161 | 162 | want := map[string]bool{"latency_percentiles_usec": false} 163 | commandStatsCheck(t, e, want) 164 | deleteTestKeys(t, redisSevenAddr) 165 | } 166 | -------------------------------------------------------------------------------- /exporter/lua.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/gomodule/redigo/redis" 7 | "github.com/prometheus/client_golang/prometheus" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func (e *Exporter) extractLuaScriptMetrics(ch chan<- prometheus.Metric, c redis.Conn, filename string, script []byte) error { 12 | log.Debugf("Evaluating e.options.LuaScript: %s", filename) 13 | kv, err := redis.StringMap(doRedisCmd(c, "EVAL", script, 0, 0)) 14 | if err != nil { 15 | log.Errorf("LuaScript error: %v", err) 16 | e.registerConstMetricGauge(ch, "script_result", 0, filename) 17 | return err 18 | } 19 | 20 | if len(kv) == 0 { 21 | log.Debugf("Lua script returned no results") 22 | e.registerConstMetricGauge(ch, "script_result", 2, filename) 23 | return nil 24 | } 25 | 26 | for key, stringVal := range kv { 27 | val, err := strconv.ParseFloat(stringVal, 64) 28 | if err != nil { 29 | log.Errorf("Error parsing lua script results, err: %s", err) 30 | e.registerConstMetricGauge(ch, "script_result", 0, filename) 31 | return err 32 | } 33 | e.registerConstMetricGauge(ch, "script_values", val, key, filename) 34 | } 35 | e.registerConstMetricGauge(ch, "script_result", 1, filename) 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /exporter/lua_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | ) 11 | 12 | func TestLuaScript(t *testing.T) { 13 | for _, tst := range []struct { 14 | Name string 15 | Script string 16 | ExpectedKeys int 17 | ExpectedError bool 18 | Wants []string 19 | }{ 20 | { 21 | Name: "ok1", 22 | Script: `return {"a", "11", "b", "12", "c", "13"}`, 23 | ExpectedKeys: 4, 24 | Wants: []string{`test_exporter_last_scrape_error{err=""} 0`, `test_script_values{filename="test.lua",key="a"} 11`, `test_script_values{filename="test.lua",key="b"} 12`, `test_script_values{filename="test.lua",key="c"} 13`, `test_script_result{filename="test.lua"} 1`}, 25 | }, 26 | { 27 | Name: "ok2", 28 | Script: `return {"key1", "6389"}`, 29 | ExpectedKeys: 4, 30 | Wants: []string{`test_exporter_last_scrape_error{err=""} 0`, `test_script_values{filename="test.lua",key="key1"} 6389`, `test_script_result{filename="test.lua"} 1`}, 31 | }, 32 | { 33 | Name: "ok3", 34 | Script: `return {} `, 35 | ExpectedKeys: 1, 36 | Wants: []string{`test_script_result{filename="test.lua"} 2`}, 37 | }, 38 | { 39 | Name: "borked1", 40 | Script: `return {"key1" BROKEN `, 41 | ExpectedKeys: 1, 42 | ExpectedError: true, 43 | Wants: []string{`test_exporter_last_scrape_error{err="ERR Error compiling script`, `test_script_result{filename="test.lua"} 0`}, 44 | }, 45 | { 46 | Name: "borked2", 47 | Script: `return {"key1", "abc"}`, 48 | ExpectedKeys: 1, 49 | ExpectedError: true, 50 | Wants: []string{`test_exporter_last_scrape_error{err="strconv.ParseFloat: parsing \"abc\": invalid syntax"} 1`, `test_script_result{filename="test.lua"} 0`}, 51 | }, 52 | } { 53 | t.Run(tst.Name, func(t *testing.T) { 54 | e, _ := NewRedisExporter( 55 | os.Getenv("TEST_REDIS_URI"), 56 | Options{ 57 | Namespace: "test", Registry: prometheus.NewRegistry(), 58 | LuaScript: map[string][]byte{"test.lua": []byte(tst.Script)}, 59 | }) 60 | ts := httptest.NewServer(e) 61 | defer ts.Close() 62 | 63 | body := downloadURL(t, ts.URL+"/metrics") 64 | 65 | for _, want := range tst.Wants { 66 | if !strings.Contains(body, want) { 67 | t.Errorf(`error, expected string "%s" in body, got body: \n\n%s`, want, body) 68 | } 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /exporter/metrics.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var metricNameRE = regexp.MustCompile(`[^a-zA-Z0-9_]`) 14 | 15 | func sanitizeMetricName(n string) string { 16 | return metricNameRE.ReplaceAllString(n, "_") 17 | } 18 | 19 | func newMetricDescr(namespace string, metricName string, docString string, labels []string) *prometheus.Desc { 20 | return prometheus.NewDesc(prometheus.BuildFQName(namespace, "", metricName), docString, labels, nil) 21 | } 22 | 23 | func (e *Exporter) includeMetric(s string) bool { 24 | if strings.HasPrefix(s, "db") || strings.HasPrefix(s, "cmdstat_") || strings.HasPrefix(s, "cluster_") { 25 | return true 26 | } 27 | if _, ok := e.metricMapGauges[s]; ok { 28 | return true 29 | } 30 | 31 | _, ok := e.metricMapCounters[s] 32 | return ok 33 | } 34 | 35 | func (e *Exporter) parseAndRegisterConstMetric(ch chan<- prometheus.Metric, fieldKey, fieldValue string) { 36 | orgMetricName := sanitizeMetricName(fieldKey) 37 | metricName := orgMetricName 38 | if newName, ok := e.metricMapGauges[metricName]; ok { 39 | metricName = newName 40 | } else { 41 | if newName, ok := e.metricMapCounters[metricName]; ok { 42 | metricName = newName 43 | } 44 | } 45 | 46 | var err error 47 | var val float64 48 | 49 | switch fieldValue { 50 | 51 | case "ok", "true": 52 | val = 1 53 | 54 | case "err", "fail", "false": 55 | val = 0 56 | 57 | default: 58 | val, err = strconv.ParseFloat(fieldValue, 64) 59 | 60 | } 61 | if err != nil { 62 | log.Debugf("couldn't parse %s, err: %s", fieldValue, err) 63 | return 64 | } 65 | 66 | t := prometheus.GaugeValue 67 | if e.metricMapCounters[orgMetricName] != "" { 68 | t = prometheus.CounterValue 69 | } 70 | 71 | switch metricName { 72 | case "latest_fork_usec": 73 | metricName = "latest_fork_seconds" 74 | val = val / 1e6 75 | } 76 | 77 | e.registerConstMetric(ch, metricName, val, t) 78 | } 79 | 80 | func (e *Exporter) registerConstMetricGauge(ch chan<- prometheus.Metric, metric string, val float64, labels ...string) { 81 | e.registerConstMetric(ch, metric, val, prometheus.GaugeValue, labels...) 82 | } 83 | 84 | func (e *Exporter) registerConstMetric(ch chan<- prometheus.Metric, metric string, val float64, valType prometheus.ValueType, labelValues ...string) { 85 | var desc *prometheus.Desc 86 | if len(labelValues) == 0 { 87 | desc = e.createMetricDescription(metric, nil) 88 | } else { 89 | desc = e.mustFindMetricDescription(metric) 90 | } 91 | 92 | m, err := prometheus.NewConstMetric(desc, valType, val, labelValues...) 93 | if err != nil { 94 | log.Debugf("registerConstMetric( %s , %.2f) err: %s", metric, val, err) 95 | return 96 | } 97 | 98 | ch <- m 99 | } 100 | 101 | func (e *Exporter) registerConstSummary(ch chan<- prometheus.Metric, metric string, count uint64, sum float64, latencyMap map[float64]float64, labelValues ...string) { 102 | // Create a constant summary from values we got from a 3rd party telemetry system. 103 | summary := prometheus.MustNewConstSummary( 104 | e.mustFindMetricDescription(metric), 105 | count, sum, 106 | latencyMap, 107 | labelValues..., 108 | ) 109 | ch <- summary 110 | } 111 | 112 | func (e *Exporter) registerConstHistogram(ch chan<- prometheus.Metric, metric string, count uint64, sum float64, buckets map[float64]uint64, labelValues ...string) { 113 | histogram := prometheus.MustNewConstHistogram( 114 | e.mustFindMetricDescription(metric), 115 | count, sum, 116 | buckets, 117 | labelValues..., 118 | ) 119 | ch <- histogram 120 | } 121 | 122 | func (e *Exporter) mustFindMetricDescription(metricName string) *prometheus.Desc { 123 | description, found := e.metricDescriptions[metricName] 124 | if !found { 125 | panic(fmt.Sprintf("couldn't find metric description for %s", metricName)) 126 | } 127 | return description 128 | } 129 | 130 | func (e *Exporter) createMetricDescription(metricName string, labels []string) *prometheus.Desc { 131 | if desc, found := e.metricDescriptions[metricName]; found { 132 | return desc 133 | } 134 | d := newMetricDescr(e.options.Namespace, metricName, metricName+" metric", labels) 135 | e.metricDescriptions[metricName] = d 136 | return d 137 | } 138 | -------------------------------------------------------------------------------- /exporter/metrics_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | ) 9 | 10 | func TestSanitizeMetricName(t *testing.T) { 11 | tsts := map[string]string{ 12 | "cluster_stats_messages_auth-req_received": "cluster_stats_messages_auth_req_received", 13 | "cluster_stats_messages_auth_req_received": "cluster_stats_messages_auth_req_received", 14 | } 15 | 16 | for m, want := range tsts { 17 | if got := sanitizeMetricName(m); got != want { 18 | t.Errorf("sanitizeMetricName( %s ) error, want: %s, got: %s", m, want, got) 19 | } 20 | } 21 | } 22 | 23 | func TestRegisterConstHistogram(t *testing.T) { 24 | exp := getTestExporter() 25 | metricName := "foo" 26 | ch := make(chan prometheus.Metric) 27 | go func() { 28 | exp.createMetricDescription(metricName, []string{"test"}) 29 | exp.registerConstHistogram(ch, metricName, 12, .24, map[float64]uint64{}, "test") 30 | close(ch) 31 | }() 32 | 33 | for m := range ch { 34 | if strings.Contains(m.Desc().String(), metricName) { 35 | return 36 | } 37 | } 38 | t.Errorf("Histogram was not registered") 39 | } 40 | 41 | func TestFindOrCreateMetricsDescriptionFindExisting(t *testing.T) { 42 | exp := getTestExporter() 43 | exp.metricDescriptions = map[string]*prometheus.Desc{} 44 | 45 | metricName := "foo" 46 | labels := []string{"1", "2"} 47 | 48 | ret := exp.createMetricDescription(metricName, labels) 49 | ret2 := exp.createMetricDescription(metricName, labels) 50 | 51 | if ret == nil || ret2 == nil || ret != ret2 { 52 | t.Errorf("Unexpected return values: (%v, %v)", ret, ret2) 53 | } 54 | 55 | if len(exp.metricDescriptions) != 1 { 56 | t.Errorf("Unexpected metricDescriptions entry count.") 57 | } 58 | } 59 | 60 | func TestFindOrCreateMetricsDescriptionCreateNew(t *testing.T) { 61 | exp := getTestExporter() 62 | exp.metricDescriptions = map[string]*prometheus.Desc{} 63 | 64 | metricName := "foo" 65 | labels := []string{"1", "2"} 66 | 67 | ret := exp.createMetricDescription(metricName, labels) 68 | 69 | if ret == nil { 70 | t.Errorf("Unexpected return value: %s", ret) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /exporter/modules.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gomodule/redigo/redis" 7 | "github.com/prometheus/client_golang/prometheus" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func (e *Exporter) extractModulesMetrics(ch chan<- prometheus.Metric, c redis.Conn) { 12 | info, err := redis.String(doRedisCmd(c, "INFO", "MODULES")) 13 | if err != nil { 14 | log.Errorf("extractSearchMetrics() err: %s", err) 15 | return 16 | } 17 | 18 | lines := strings.Split(info, "\r\n") 19 | for _, line := range lines { 20 | log.Debugf("info: %s", line) 21 | 22 | split := strings.Split(line, ":") 23 | if len(split) != 2 { 24 | continue 25 | } 26 | 27 | if split[0] == "module" { 28 | // module format: 'module:name=,ver=21005,api=1,filters=0,usedby=[],using=[],options=[]' 29 | module := strings.Split(split[1], ",") 30 | if len(module) != 7 { 31 | continue 32 | } 33 | e.registerConstMetricGauge(ch, "module_info", 1, 34 | strings.Split(module[0], "=")[1], 35 | strings.Split(module[1], "=")[1], 36 | strings.Split(module[2], "=")[1], 37 | strings.Split(module[3], "=")[1], 38 | strings.Split(module[4], "=")[1], 39 | strings.Split(module[5], "=")[1], 40 | ) 41 | continue 42 | } 43 | 44 | fieldKey := split[0] 45 | fieldValue := split[1] 46 | 47 | if !e.includeMetric(fieldKey) { 48 | continue 49 | } 50 | e.parseAndRegisterConstMetric(ch, fieldKey, fieldValue) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /exporter/modules_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | func TestModulesv74(t *testing.T) { 12 | if os.Getenv("TEST_REDIS_MODULES_URI") == "" { 13 | t.Skipf("TEST_REDIS_MODULES_URI not set - skipping") 14 | } 15 | 16 | tsts := []struct { 17 | addr string 18 | inclModulesMetrics bool 19 | wantModulesMetrics bool 20 | }{ 21 | {addr: os.Getenv("TEST_REDIS_MODULES_URI"), inclModulesMetrics: true, wantModulesMetrics: true}, 22 | {addr: os.Getenv("TEST_REDIS_MODULES_URI"), inclModulesMetrics: false, wantModulesMetrics: false}, 23 | {addr: os.Getenv("TEST_REDIS_URI"), inclModulesMetrics: true, wantModulesMetrics: false}, 24 | {addr: os.Getenv("TEST_REDIS_URI"), inclModulesMetrics: false, wantModulesMetrics: false}, 25 | } 26 | 27 | for _, tst := range tsts { 28 | e, _ := NewRedisExporter(tst.addr, Options{Namespace: "test", InclModulesMetrics: tst.inclModulesMetrics}) 29 | 30 | chM := make(chan prometheus.Metric) 31 | go func() { 32 | e.Collect(chM) 33 | close(chM) 34 | }() 35 | 36 | wantedMetrics := map[string]bool{ 37 | "module_info": false, 38 | "search_number_of_indexes": false, 39 | "search_used_memory_indexes_bytes": false, 40 | "search_indexing_time_ms_total": false, 41 | "search_global_idle": false, 42 | "search_global_total": false, 43 | "search_collected_bytes": false, 44 | "search_cycles_total": false, 45 | "search_run_ms_total": false, 46 | "search_dialect_1": false, 47 | "search_dialect_2": false, 48 | "search_dialect_3": false, 49 | "search_dialect_4": false, 50 | } 51 | 52 | for m := range chM { 53 | for want := range wantedMetrics { 54 | if strings.Contains(m.Desc().String(), want) { 55 | wantedMetrics[want] = true 56 | } 57 | } 58 | } 59 | 60 | if tst.wantModulesMetrics { 61 | for want, found := range wantedMetrics { 62 | if !found { 63 | t.Errorf("%s was *not* found in Redis Modules metrics but expected", want) 64 | } 65 | } 66 | } else if !tst.wantModulesMetrics { 67 | for want, found := range wantedMetrics { 68 | if found { 69 | t.Errorf("%s was *found* in Redis Modules metrics but *not* expected", want) 70 | } 71 | } 72 | } 73 | } 74 | } 75 | 76 | func TestModulesv80(t *testing.T) { 77 | if os.Getenv("TEST_REDIS8_URI") == "" { 78 | t.Skipf("TEST_REDIS8_URI not set - skipping") 79 | } 80 | 81 | tsts := []struct { 82 | addr string 83 | inclModulesMetrics bool 84 | wantModulesMetrics bool 85 | }{ 86 | {addr: os.Getenv("TEST_REDIS8_URI"), inclModulesMetrics: true, wantModulesMetrics: true}, 87 | {addr: os.Getenv("TEST_REDIS8_URI"), inclModulesMetrics: false, wantModulesMetrics: false}, 88 | {addr: os.Getenv("TEST_REDIS_URI"), inclModulesMetrics: true, wantModulesMetrics: false}, 89 | {addr: os.Getenv("TEST_REDIS_URI"), inclModulesMetrics: false, wantModulesMetrics: false}, 90 | } 91 | 92 | for _, tst := range tsts { 93 | e, _ := NewRedisExporter(tst.addr, Options{Namespace: "test", InclModulesMetrics: tst.inclModulesMetrics}) 94 | 95 | chM := make(chan prometheus.Metric) 96 | go func() { 97 | e.Collect(chM) 98 | close(chM) 99 | }() 100 | 101 | wantedMetrics := map[string]bool{ 102 | "module_info": false, 103 | "search_number_of_indexes": false, 104 | "search_used_memory_indexes_bytes": false, 105 | "search_indexing_time_ms_total": false, 106 | "search_dialect_1": false, 107 | "search_dialect_2": false, 108 | "search_dialect_3": false, 109 | "search_dialect_4": false, 110 | "search_number_of_active_indexes": false, 111 | "search_number_of_active_indexes_running_queries": false, 112 | "search_number_of_active_indexes_indexing": false, 113 | "search_total_active_write_threads": false, 114 | "search_smallest_memory_index_bytes": false, 115 | "search_largest_memory_index_bytes": false, 116 | "search_used_memory_vector_index_bytes": false, 117 | "search_global_idle_user": false, 118 | "search_global_idle_internal": false, 119 | "search_global_total_user": false, 120 | "search_global_total_internal": false, 121 | "search_gc_collected_bytes": false, 122 | "search_gc_total_docs_not_collected": false, 123 | "search_gc_marked_deleted_vectors": false, 124 | "search_errors_indexing_failures": false, 125 | "search_gc_cycles_total": false, 126 | "search_gc_run_ms_total": false, 127 | "search_queries_processed_total": false, 128 | "search_query_commands_total": false, 129 | "search_query_execution_time_ms_total": false, 130 | "search_active_queries_total": false, 131 | } 132 | 133 | for m := range chM { 134 | for want := range wantedMetrics { 135 | if strings.Contains(m.Desc().String(), want) { 136 | wantedMetrics[want] = true 137 | } 138 | } 139 | } 140 | 141 | if tst.wantModulesMetrics { 142 | for want, found := range wantedMetrics { 143 | if !found { 144 | t.Errorf("%s was *not* found in Redis Modules metrics but expected", want) 145 | } 146 | } 147 | } else if !tst.wantModulesMetrics { 148 | for want, found := range wantedMetrics { 149 | if found { 150 | t.Errorf("%s was *found* in Redis Modules metrics but *not* expected", want) 151 | } 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /exporter/nodes.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/gomodule/redigo/redis" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | var reNodeAddress = regexp.MustCompile(`^(?P.+):(?P\d+)@(?P\d+)(?:,(?P.+))?`) 12 | 13 | func (e *Exporter) getClusterNodes(c redis.Conn) ([]string, error) { 14 | output, err := redis.String(doRedisCmd(c, "CLUSTER", "NODES")) 15 | if err != nil { 16 | log.Errorf("Error getting cluster nodes: %s", err) 17 | return nil, err 18 | } 19 | 20 | lines := strings.Split(output, "\n") 21 | nodes := []string{} 22 | 23 | for _, line := range lines { 24 | if node, ok := parseClusterNodeString(line); ok { 25 | nodes = append(nodes, node) 26 | } 27 | } 28 | 29 | return nodes, nil 30 | } 31 | 32 | /* 33 | ... 34 | eaf69c70d876558a948ba62af0884a37d42c9627 127.0.0.1:7002@17002 master - 0 1742836359057 3 connected 10923-16383 35 | */ 36 | func parseClusterNodeString(node string) (string, bool) { 37 | log.Debugf("parseClusterNodeString node: [%s]", node) 38 | 39 | fields := strings.Fields(node) 40 | if len(fields) < 2 { 41 | log.Debugf("Invalid field count for node: %s", node) 42 | return "", false 43 | } 44 | 45 | address := reNodeAddress.FindStringSubmatch(fields[1]) 46 | if len(address) < 3 { 47 | log.Debugf("Invalid format for node address, got: %s", fields[1]) 48 | return "", false 49 | } 50 | 51 | return address[1] + ":" + address[2], true 52 | } 53 | -------------------------------------------------------------------------------- /exporter/nodes_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "os" 5 | "slices" 6 | "testing" 7 | ) 8 | 9 | func TestNodesGetClusterNodes(t *testing.T) { 10 | host := os.Getenv("TEST_REDIS_CLUSTER_MASTER_URI") 11 | if host == "" { 12 | t.Skipf("TEST_REDIS_CLUSTER_MASTER_URI not set - skipping") 13 | } 14 | 15 | e, _ := NewRedisExporter(host, Options{}) 16 | c, err := e.connectToRedisCluster() 17 | if err != nil { 18 | t.Fatalf("connectToRedisCluster() err: %s", err) 19 | } 20 | defer c.Close() 21 | 22 | nodes, err := e.getClusterNodes(c) 23 | if err != nil { 24 | t.Fatalf("getClusterNodes() err: %s", err) 25 | } 26 | 27 | tsts := []struct { 28 | node string 29 | ok bool 30 | }{ 31 | {node: "127.0.0.1:7003", ok: true}, 32 | {node: "127.0.0.1:7002", ok: true}, 33 | {node: "127.0.0.1:7005", ok: true}, 34 | {node: "127.0.0.1:7001", ok: true}, 35 | {node: "127.0.0.1:7004", ok: true}, 36 | {node: "127.0.0.1:7000", ok: true}, 37 | 38 | {node: "", ok: false}, 39 | {node: " ", ok: false}, 40 | {node: "127.0.0.1", ok: false}, 41 | {node: "127.0.0.1:8000", ok: false}, 42 | } 43 | 44 | for _, tst := range tsts { 45 | t.Run(tst.node, func(t *testing.T) { 46 | found := slices.Contains(nodes, tst.node) 47 | if found != tst.ok { 48 | t.Errorf("Test failed for node: %s expected: %t, got: %t", tst.node, tst.ok, found) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestParseClusterNodeString(t *testing.T) { 55 | tsts := []struct { 56 | line string 57 | node string 58 | ok bool 59 | }{ 60 | // The following are examples of the output of the CLUSTER NODES command. 61 | // https://redis.io/docs/latest/commands/cluster-nodes/ 62 | {line: "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004@31004,hostname4 slave e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 0 1426238317239 4 connected", node: "127.0.0.1:30004", ok: true}, 63 | {line: "67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 127.0.0.1:30002@31002,hostname2 master - 0 1426238316232 2 connected 5461-10922", node: "127.0.0.1:30002", ok: true}, 64 | {line: "292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 127.0.0.1:30003@31003,hostname3 master - 0 1426238318243 3 connected 10923-16383", node: "127.0.0.1:30003", ok: true}, 65 | {line: "6ec23923021cf3ffec47632106199cb7f496ce01 127.0.0.1:30005@31005,hostname5 slave 67ed2db8d677e59ec4a4cefb06858cf2a1a89fa1 0 1426238316232 5 connected", node: "127.0.0.1:30005", ok: true}, 66 | {line: "824fe116063bc5fcf9f4ffd895bc17aee7731ac3 127.0.0.1:30006@31006,hostname6 slave 292f8b365bb7edb5e285caf0b7e6ddc7265d2f4f 0 1426238317741 6 connected", node: "127.0.0.1:30006", ok: true}, 67 | {line: "e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 127.0.0.1:30001@31001,hostname1 myself,master - 0 0 1 connected 0-5460", node: "127.0.0.1:30001", ok: true}, 68 | {line: "e7d1eecce10fd6bb5eb35b9f99a514335d9ba9ca 127.0.0.1:30001@31001 myself,master - 0 0 1 connected 0-5460", node: "127.0.0.1:30001", ok: true}, 69 | 70 | {line: "07c37dfeb235213a872192d90877d0cd55635b91", ok: false}, 71 | {line: "07c37dfeb235213a872192d90877d0cd55635b91 127.0.0.1:30004,hostname4 slave", ok: false}, 72 | {line: "127.0.0.1:30005,hostname5", ok: false}, 73 | } 74 | 75 | for _, tst := range tsts { 76 | t.Run(tst.line, func(t *testing.T) { 77 | node, ok := parseClusterNodeString(tst.line) 78 | 79 | if ok != tst.ok { 80 | t.Errorf("Test failed for line: %s", tst.line) 81 | return 82 | } 83 | if node != tst.node { 84 | t.Errorf("Node not matching, expected: %s, got: %s", tst.node, node) 85 | return 86 | } 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /exporter/pwd_file.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // LoadPwdFile reads the redis password file and returns the password map 11 | func LoadPwdFile(passwordFile string) (map[string]string, error) { 12 | res := make(map[string]string) 13 | 14 | log.Debugf("start load password file: %s", passwordFile) 15 | bytes, err := os.ReadFile(passwordFile) 16 | if err != nil { 17 | log.Warnf("load password file failed: %s", err) 18 | return nil, err 19 | } 20 | err = json.Unmarshal(bytes, &res) 21 | if err != nil { 22 | log.Warnf("password file format error: %s", err) 23 | return nil, err 24 | } 25 | 26 | log.Infof("Loaded %d entries from %s", len(res), passwordFile) 27 | for k := range res { 28 | log.Debugf("%s", k) 29 | } 30 | 31 | return res, nil 32 | } 33 | -------------------------------------------------------------------------------- /exporter/pwd_file_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | ) 14 | 15 | func TestLoadPwdFile(t *testing.T) { 16 | for _, tst := range []struct { 17 | name string 18 | pwdFile string 19 | ok bool 20 | }{ 21 | { 22 | name: "load-password-file-success", 23 | pwdFile: "../contrib/sample-pwd-file.json", 24 | ok: true, 25 | }, 26 | { 27 | name: "load-password-file-missing", 28 | pwdFile: "non-existent.json", 29 | ok: false, 30 | }, 31 | { 32 | name: "load-password-file-malformed", 33 | pwdFile: "../contrib/sample-pwd-file.json-malformed", 34 | ok: false, 35 | }, 36 | } { 37 | t.Run(tst.name, func(t *testing.T) { 38 | _, err := LoadPwdFile(tst.pwdFile) 39 | if err == nil && !tst.ok { 40 | t.Fatalf("Test Failed, result is not what we want") 41 | } 42 | if err != nil && tst.ok { 43 | t.Fatalf("Test Failed, result is not what we want") 44 | } 45 | }) 46 | } 47 | } 48 | 49 | func TestPasswordMap(t *testing.T) { 50 | pwdFile := "../contrib/sample-pwd-file.json" 51 | passwordMap, err := LoadPwdFile(pwdFile) 52 | if err != nil { 53 | t.Fatalf("Test Failed, error: %v", err) 54 | } 55 | 56 | if len(passwordMap) == 0 { 57 | t.Fatalf("Password map is empty - failing") 58 | } 59 | 60 | for _, tst := range []struct { 61 | name string 62 | addr string 63 | want string 64 | }{ 65 | {name: "password-hit", addr: "redis://localhost:16380", want: "redis-password"}, 66 | {name: "password-missed", addr: "Non-existent-redis-host", want: ""}, 67 | } { 68 | t.Run(tst.name, func(t *testing.T) { 69 | pwd := passwordMap[tst.addr] 70 | if !strings.Contains(pwd, tst.want) { 71 | t.Errorf("redis host: %s password is not what we want", tst.addr) 72 | } 73 | }) 74 | } 75 | } 76 | 77 | func TestHTTPScrapeWithPasswordFile(t *testing.T) { 78 | if os.Getenv("TEST_PWD_REDIS_URI") == "" { 79 | t.Skipf("Skipping TestHTTPScrapeWithPasswordFile, missing env variables") 80 | } 81 | 82 | pwdFile := "../contrib/sample-pwd-file.json" 83 | passwordMap, err := LoadPwdFile(pwdFile) 84 | if err != nil { 85 | t.Fatalf("Test Failed, error: %v", err) 86 | } 87 | 88 | if len(passwordMap) == 0 { 89 | t.Fatalf("Password map is empty!") 90 | } 91 | for _, tst := range []struct { 92 | name string 93 | addr string 94 | wants []string 95 | useWrongPassword bool 96 | wantStatusCode int 97 | }{ 98 | {name: "scrape-pwd-file", addr: os.Getenv("TEST_PWD_REDIS_URI"), wants: []string{ 99 | "uptime_in_seconds", 100 | "test_up 1", 101 | }}, 102 | {name: "scrape-pwd-file-wrong-password", addr: "redis://localhost:16380", useWrongPassword: true, wants: []string{ 103 | "test_up 0", 104 | }}, 105 | } { 106 | if tst.useWrongPassword { 107 | passwordMap[tst.addr] = "wrong-password" 108 | } 109 | options := Options{ 110 | Namespace: "test", 111 | PasswordMap: passwordMap, 112 | LuaScript: map[string][]byte{ 113 | "test.lua": []byte(`return {"a", "11", "b", "12", "c", "13"}`), 114 | }, 115 | Registry: prometheus.NewRegistry(), 116 | } 117 | t.Run(tst.name, func(t *testing.T) { 118 | e, _ := NewRedisExporter(tst.addr, options) 119 | ts := httptest.NewServer(e) 120 | 121 | u := ts.URL 122 | u += "/scrape" 123 | v := url.Values{} 124 | v.Add("target", tst.addr) 125 | 126 | up, _ := url.Parse(u) 127 | up.RawQuery = v.Encode() 128 | u = up.String() 129 | 130 | wantStatusCode := http.StatusOK 131 | if tst.wantStatusCode != 0 { 132 | wantStatusCode = tst.wantStatusCode 133 | } 134 | 135 | gotStatusCode, body := downloadURLWithStatusCode(t, u) 136 | 137 | if gotStatusCode != wantStatusCode { 138 | t.Fatalf("got status code: %d wanted: %d", gotStatusCode, wantStatusCode) 139 | return 140 | } 141 | 142 | // we can stop here if we expected a non-200 response 143 | if wantStatusCode != http.StatusOK { 144 | return 145 | } 146 | 147 | for _, want := range tst.wants { 148 | if !strings.Contains(body, want) { 149 | t.Errorf("url: %s want metrics to include %q, have:\n%s", u, want, body) 150 | break 151 | } 152 | } 153 | ts.Close() 154 | }) 155 | } 156 | } 157 | 158 | func TestHTTPScrapeWithUsername(t *testing.T) { 159 | if os.Getenv("TEST_USER_PWD_REDIS_URI") == "" { 160 | t.Skipf("Skipping TestHTTPScrapeWithPasswordFile, missing env variables") 161 | } 162 | 163 | pwdFile := "../contrib/sample-pwd-file.json" 164 | passwordMap, err := LoadPwdFile(pwdFile) 165 | if err != nil { 166 | t.Fatalf("Test Failed, error: %v", err) 167 | } 168 | 169 | if len(passwordMap) == 0 { 170 | t.Fatalf("Password map is empty!") 171 | } 172 | 173 | // use provided uri but remove password before sending it over the wire 174 | // after all, we want to test the lookup in the password map 175 | u, err := url.Parse(os.Getenv("TEST_USER_PWD_REDIS_URI")) 176 | u.User = url.User(u.User.Username()) 177 | uriWithUser := u.String() 178 | uriWithUser = strings.Replace(uriWithUser, fmt.Sprintf(":@%s", u.Host), fmt.Sprintf("@%s", u.Host), 1) 179 | 180 | for _, tst := range []struct { 181 | name string 182 | addr string 183 | wants []string 184 | wantStatusCode int 185 | }{ 186 | { 187 | name: "scrape-pwd-file", 188 | wantStatusCode: http.StatusOK, 189 | addr: uriWithUser, wants: []string{ 190 | "uptime_in_seconds", 191 | "test_up 1", 192 | }}, 193 | } { 194 | options := Options{ 195 | Namespace: "test", 196 | PasswordMap: passwordMap, 197 | Registry: prometheus.NewRegistry(), 198 | } 199 | t.Run(tst.name, func(t *testing.T) { 200 | e, _ := NewRedisExporter(tst.addr, options) 201 | ts := httptest.NewServer(e) 202 | 203 | u := ts.URL 204 | u += "/scrape" 205 | v := url.Values{} 206 | v.Add("target", tst.addr) 207 | 208 | up, _ := url.Parse(u) 209 | up.RawQuery = v.Encode() 210 | u = up.String() 211 | 212 | gotStatusCode, body := downloadURLWithStatusCode(t, u) 213 | 214 | if gotStatusCode != tst.wantStatusCode { 215 | t.Fatalf("got status code: %d wanted: %d", gotStatusCode, tst.wantStatusCode) 216 | return 217 | } 218 | 219 | // we can stop here if we expected a non-200 response 220 | if tst.wantStatusCode != http.StatusOK { 221 | return 222 | } 223 | 224 | for _, want := range tst.wants { 225 | if !strings.Contains(body, want) { 226 | t.Errorf("url: %s want metrics to include %q, have:\n%s", u, want, body) 227 | break 228 | } 229 | } 230 | ts.Close() 231 | }) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /exporter/redis.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gomodule/redigo/redis" 10 | "github.com/mna/redisc" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func (e *Exporter) configureOptions(uri string) ([]redis.DialOption, error) { 15 | tlsConfig, err := e.CreateClientTLSConfig() 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | options := []redis.DialOption{ 21 | redis.DialConnectTimeout(e.options.ConnectionTimeouts), 22 | redis.DialReadTimeout(e.options.ConnectionTimeouts), 23 | redis.DialWriteTimeout(e.options.ConnectionTimeouts), 24 | redis.DialTLSConfig(tlsConfig), 25 | redis.DialUseTLS(strings.HasPrefix(e.redisAddr, "rediss://")), 26 | } 27 | 28 | if e.options.User != "" { 29 | options = append(options, redis.DialUsername(e.options.User)) 30 | } 31 | 32 | if e.options.Password != "" { 33 | options = append(options, redis.DialPassword(e.options.Password)) 34 | } 35 | 36 | if pwd, ok := e.lookupPasswordInPasswordMap(uri); ok && pwd != "" { 37 | options = append(options, redis.DialPassword(pwd)) 38 | } 39 | 40 | return options, nil 41 | } 42 | 43 | func (e *Exporter) lookupPasswordInPasswordMap(uri string) (string, bool) { 44 | u, err := url.Parse(uri) 45 | if err != nil { 46 | return "", false 47 | } 48 | 49 | if e.options.User != "" { 50 | u.User = url.User(e.options.User) 51 | } 52 | uri = u.String() 53 | 54 | // strip solo ":" if present in uri that has a username (and no pwd) 55 | uri = strings.Replace(uri, fmt.Sprintf(":@%s", u.Host), fmt.Sprintf("@%s", u.Host), 1) 56 | 57 | log.Debugf("looking up in pwd map, uri: %s", uri) 58 | if pwd, ok := e.options.PasswordMap[uri]; ok && pwd != "" { 59 | return pwd, true 60 | } 61 | return "", false 62 | } 63 | 64 | func (e *Exporter) connectToRedis() (redis.Conn, error) { 65 | uri := e.redisAddr 66 | if !strings.Contains(uri, "://") { 67 | uri = "redis://" + uri 68 | } 69 | 70 | options, err := e.configureOptions(uri) 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | log.Debugf("Trying DialURL(): %s", uri) 76 | c, err := redis.DialURL(uri, options...) 77 | if err != nil { 78 | log.Debugf("DialURL() failed, err: %s", err) 79 | if frags := strings.Split(e.redisAddr, "://"); len(frags) == 2 { 80 | log.Debugf("Trying: Dial(): %s %s", frags[0], frags[1]) 81 | c, err = redis.Dial(frags[0], frags[1], options...) 82 | } else { 83 | log.Debugf("Trying: Dial(): tcp %s", e.redisAddr) 84 | c, err = redis.Dial("tcp", e.redisAddr, options...) 85 | } 86 | } 87 | return c, err 88 | } 89 | 90 | func (e *Exporter) connectToRedisCluster() (redis.Conn, error) { 91 | uri := e.redisAddr 92 | if !strings.Contains(uri, "://") { 93 | uri = "redis://" + uri 94 | } 95 | 96 | options, err := e.configureOptions(uri) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | // remove url scheme for redis.Cluster.StartupNodes 102 | if strings.Contains(uri, "://") { 103 | u, _ := url.Parse(uri) 104 | if u.Port() == "" { 105 | uri = u.Host + ":6379" 106 | } else { 107 | uri = u.Host 108 | } 109 | } else { 110 | if frags := strings.Split(uri, ":"); len(frags) != 2 { 111 | uri = uri + ":6379" 112 | } 113 | } 114 | 115 | log.Debugf("Creating cluster object") 116 | cluster := redisc.Cluster{ 117 | StartupNodes: []string{uri}, 118 | DialOptions: options, 119 | } 120 | log.Debugf("Running refresh on cluster object") 121 | if err := cluster.Refresh(); err != nil { 122 | log.Errorf("Cluster refresh failed: %v", err) 123 | return nil, fmt.Errorf("cluster refresh failed: %w", err) 124 | } 125 | 126 | log.Debugf("Creating redis connection object") 127 | conn, err := cluster.Dial() 128 | if err != nil { 129 | log.Errorf("Dial failed: %v", err) 130 | return nil, fmt.Errorf("dial failed: %w", err) 131 | } 132 | 133 | c, err := redisc.RetryConn(conn, 10, 100*time.Millisecond) 134 | if err != nil { 135 | log.Errorf("RetryConn failed: %v", err) 136 | return nil, fmt.Errorf("retryConn failed: %w", err) 137 | } 138 | 139 | return c, err 140 | } 141 | 142 | func doRedisCmd(c redis.Conn, cmd string, args ...interface{}) (interface{}, error) { 143 | log.Debugf("c.Do() - running command: %s args: [%v]", cmd, args) 144 | res, err := c.Do(cmd, args...) 145 | if err != nil { 146 | log.Debugf("c.Do() - err: %s", err) 147 | } 148 | log.Debugf("c.Do() - done") 149 | return res, err 150 | } 151 | -------------------------------------------------------------------------------- /exporter/redis_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "net/http/httptest" 5 | "net/url" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | ) 12 | 13 | func TestHostVariations(t *testing.T) { 14 | host := strings.ReplaceAll(os.Getenv("TEST_REDIS_URI"), "redis://", "") 15 | 16 | for _, prefix := range []string{"", "redis://", "tcp://", ""} { 17 | e, _ := NewRedisExporter(prefix+host, Options{SkipTLSVerification: true}) 18 | c, err := e.connectToRedis() 19 | if err != nil { 20 | t.Errorf("connectToRedis() err: %s", err) 21 | continue 22 | } 23 | 24 | if _, err := c.Do("PING", ""); err != nil { 25 | t.Errorf("PING err: %s", err) 26 | } 27 | 28 | c.Close() 29 | } 30 | } 31 | 32 | func TestValkeyScheme(t *testing.T) { 33 | host := os.Getenv("TEST_VALKEY8_URI") 34 | 35 | e, _ := NewRedisExporter(host, Options{SkipTLSVerification: true}) 36 | c, err := e.connectToRedis() 37 | if err != nil { 38 | t.Fatalf("connectToRedis() err: %s", err) 39 | } 40 | 41 | if _, err := c.Do("PING", ""); err != nil { 42 | t.Errorf("PING err: %s", err) 43 | } 44 | 45 | c.Close() 46 | } 47 | 48 | func TestPasswordProtectedInstance(t *testing.T) { 49 | userAddr := os.Getenv("TEST_USER_PWD_REDIS_URI") 50 | if userAddr == "" { 51 | t.Skipf("Skipping TestHTTPScrapeWithPasswordFile, missing env variables") 52 | } 53 | 54 | parsedPassword := "" 55 | parsed, err := url.Parse(userAddr) 56 | if err == nil && parsed.User != nil { 57 | parsedPassword, _ = parsed.User.Password() 58 | } 59 | 60 | tsts := []struct { 61 | name string 62 | addr string 63 | user string 64 | pwd string 65 | }{ 66 | { 67 | name: "TEST_PWD_REDIS_URI", 68 | addr: os.Getenv("TEST_PWD_REDIS_URI"), 69 | }, 70 | { 71 | name: "TEST_USER_PWD_REDIS_URI", 72 | addr: userAddr, 73 | }, 74 | { 75 | name: "parsed-TEST_USER_PWD_REDIS_URI", 76 | addr: parsed.Host, 77 | user: parsed.User.Username(), 78 | pwd: parsedPassword, 79 | }, 80 | } 81 | 82 | for _, tst := range tsts { 83 | t.Run(tst.name, func(t *testing.T) { 84 | e, _ := NewRedisExporter( 85 | tst.addr, 86 | Options{ 87 | Namespace: "test", 88 | Registry: prometheus.NewRegistry(), 89 | User: tst.user, 90 | Password: tst.pwd, 91 | }) 92 | ts := httptest.NewServer(e) 93 | defer ts.Close() 94 | 95 | body := downloadURL(t, ts.URL+"/metrics") 96 | if !strings.Contains(body, "test_up 1") { 97 | t.Errorf(`%s - response to /metric doesn't contain "test_up 1"`, tst) 98 | } 99 | }) 100 | } 101 | } 102 | 103 | func TestPasswordInvalid(t *testing.T) { 104 | if os.Getenv("TEST_PWD_REDIS_URI") == "" { 105 | t.Skipf("TEST_PWD_REDIS_URI not set - skipping") 106 | } 107 | 108 | testPwd := "redis-password" 109 | uri := strings.Replace(os.Getenv("TEST_PWD_REDIS_URI"), testPwd, "wrong-pwd", -1) 110 | 111 | e, _ := NewRedisExporter(uri, Options{Namespace: "test", Registry: prometheus.NewRegistry()}) 112 | ts := httptest.NewServer(e) 113 | defer ts.Close() 114 | 115 | want := `test_exporter_last_scrape_error{err="dial redis: unknown network redis"} 1` 116 | body := downloadURL(t, ts.URL+"/metrics") 117 | if !strings.Contains(body, want) { 118 | t.Errorf(`error, expected string "%s" in body, got body: \n\n%s`, want, body) 119 | } 120 | } 121 | 122 | func TestConnectToClusterUsingPasswordFile(t *testing.T) { 123 | clusterUri := os.Getenv("TEST_REDIS_CLUSTER_PASSWORD_URI") 124 | if clusterUri == "" { 125 | t.Skipf("TEST_REDIS_CLUSTER_PASSWORD_URI is not set") 126 | } 127 | passMap := map[string]string{clusterUri: "redis-password"} 128 | wrongPassMap := map[string]string{"redis://redis-cluster-password-wrong:7006": "redis-password"} 129 | 130 | tsts := []struct { 131 | name string 132 | isCluster bool 133 | passMap map[string]string 134 | refreshError bool 135 | }{ 136 | {name: "ConnectToCluster using password file with cluster mode", isCluster: true, passMap: passMap, refreshError: false}, 137 | {name: "ConnectToCluster using password file without cluster mode", isCluster: false, passMap: passMap, refreshError: false}, 138 | {name: "ConnectToCluster using password file with cluster mode failed", isCluster: false, passMap: wrongPassMap, refreshError: true}, 139 | } 140 | for _, tst := range tsts { 141 | t.Run(tst.name, func(t *testing.T) { 142 | e, _ := NewRedisExporter(clusterUri, Options{ 143 | SkipTLSVerification: true, 144 | PasswordMap: tst.passMap, 145 | IsCluster: tst.isCluster, 146 | }) 147 | _, err := e.connectToRedisCluster() 148 | t.Logf("connectToRedisCluster() err: %s", err) 149 | if err != nil && strings.Contains(err.Error(), "Cluster refresh failed:") && !tst.refreshError { 150 | t.Fatalf("Test Cluster connection Failed error") 151 | } 152 | if !tst.refreshError && err != nil { 153 | t.Fatalf("Test Cluster connection Failed, err: %s", err) 154 | } 155 | }) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /exporter/sentinels.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/gomodule/redigo/redis" 9 | "github.com/prometheus/client_golang/prometheus" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func (e *Exporter) handleMetricsSentinel(ch chan<- prometheus.Metric, fieldKey string, fieldValue string) { 14 | switch fieldKey { 15 | case 16 | "sentinel_masters", 17 | "sentinel_tilt", 18 | "sentinel_running_scripts", 19 | "sentinel_scripts_queue_length", 20 | "sentinel_simulate_failure_flags": 21 | val, _ := strconv.Atoi(fieldValue) 22 | e.registerConstMetricGauge(ch, fieldKey, float64(val)) 23 | return 24 | } 25 | 26 | if masterName, masterStatus, masterAddress, masterSlaves, masterSentinels, ok := parseSentinelMasterString(fieldKey, fieldValue); ok { 27 | masterStatusNum := 0.0 28 | if masterStatus == "ok" { 29 | masterStatusNum = 1 30 | } 31 | e.registerConstMetricGauge(ch, "sentinel_master_status", masterStatusNum, masterName, masterAddress, masterStatus) 32 | e.registerConstMetricGauge(ch, "sentinel_master_slaves", masterSlaves, masterName, masterAddress) 33 | e.registerConstMetricGauge(ch, "sentinel_master_sentinels", masterSentinels, masterName, masterAddress) 34 | return 35 | } 36 | } 37 | 38 | func (e *Exporter) extractSentinelMetrics(ch chan<- prometheus.Metric, c redis.Conn) { 39 | masterDetails, err := redis.Values(doRedisCmd(c, "SENTINEL", "MASTERS")) 40 | if err != nil { 41 | log.Debugf("Error getting sentinel master details %s:", err) 42 | return 43 | } 44 | 45 | log.Debugf("Sentinel master details: %#v", masterDetails) 46 | 47 | for _, masterDetail := range masterDetails { 48 | masterDetailMap, err := redis.StringMap(masterDetail, nil) 49 | if err != nil { 50 | log.Debugf("Error getting masterDetailmap from masterDetail: %s, err: %s", masterDetail, err) 51 | continue 52 | } 53 | 54 | masterName, ok := masterDetailMap["name"] 55 | if !ok { 56 | continue 57 | } 58 | 59 | masterIp, ok := masterDetailMap["ip"] 60 | if !ok { 61 | continue 62 | } 63 | 64 | masterPort, ok := masterDetailMap["port"] 65 | if !ok { 66 | continue 67 | } 68 | masterAddr := masterIp + ":" + masterPort 69 | 70 | masterCkquorumMsg, err := redis.String(doRedisCmd(c, "SENTINEL", "CKQUORUM", masterName)) 71 | log.Debugf("Sentinel ckquorum status for master %s: %s %s", masterName, masterCkquorumMsg, err) 72 | masterCkquorumStatus := 1 73 | if err != nil { 74 | masterCkquorumStatus = 0 75 | masterCkquorumMsg = err.Error() 76 | } 77 | e.registerConstMetricGauge(ch, "sentinel_master_ckquorum_status", float64(masterCkquorumStatus), masterName, masterCkquorumMsg) 78 | 79 | masterCkquorum, _ := strconv.ParseFloat(masterDetailMap["quorum"], 64) 80 | masterFailoverTimeout, _ := strconv.ParseFloat(masterDetailMap["failover-timeout"], 64) 81 | masterParallelSyncs, _ := strconv.ParseFloat(masterDetailMap["parallel-syncs"], 64) 82 | masterDownAfterMs, _ := strconv.ParseFloat(masterDetailMap["down-after-milliseconds"], 64) 83 | 84 | e.registerConstMetricGauge(ch, "sentinel_master_setting_ckquorum", masterCkquorum, masterName, masterAddr) 85 | e.registerConstMetricGauge(ch, "sentinel_master_setting_failover_timeout", masterFailoverTimeout, masterName, masterAddr) 86 | e.registerConstMetricGauge(ch, "sentinel_master_setting_parallel_syncs", masterParallelSyncs, masterName, masterAddr) 87 | e.registerConstMetricGauge(ch, "sentinel_master_setting_down_after_milliseconds", masterDownAfterMs, masterName, masterAddr) 88 | 89 | sentinelDetails, _ := redis.Values(doRedisCmd(c, "SENTINEL", "SENTINELS", masterName)) 90 | log.Debugf("Sentinel details for master %s: %s", masterName, sentinelDetails) 91 | e.processSentinelSentinels(ch, sentinelDetails, masterName, masterAddr) 92 | 93 | slaveDetails, _ := redis.Values(doRedisCmd(c, "SENTINEL", "SLAVES", masterName)) 94 | log.Debugf("Slave details for master %s: %s", masterName, slaveDetails) 95 | e.processSentinelSlaves(ch, slaveDetails, masterName, masterAddr) 96 | } 97 | } 98 | 99 | func (e *Exporter) processSentinelSentinels(ch chan<- prometheus.Metric, sentinelDetails []interface{}, labels ...string) { 100 | 101 | // If we are here then this master is in ok state 102 | masterOkSentinels := 1 103 | 104 | for _, sentinelDetail := range sentinelDetails { 105 | sentinelDetailMap, err := redis.StringMap(sentinelDetail, nil) 106 | if err != nil { 107 | log.Debugf("Error getting sentinelDetailMap from sentinelDetail: %s, err: %s", sentinelDetail, err) 108 | continue 109 | } 110 | 111 | sentinelFlags, ok := sentinelDetailMap["flags"] 112 | if !ok { 113 | continue 114 | } 115 | if strings.Contains(sentinelFlags, "o_down") { 116 | continue 117 | } 118 | if strings.Contains(sentinelFlags, "s_down") { 119 | continue 120 | } 121 | masterOkSentinels = masterOkSentinels + 1 122 | } 123 | e.registerConstMetricGauge(ch, "sentinel_master_ok_sentinels", float64(masterOkSentinels), labels...) 124 | } 125 | 126 | func (e *Exporter) processSentinelSlaves(ch chan<- prometheus.Metric, slaveDetails []interface{}, labels ...string) { 127 | masterOkSlaves := 0 128 | for _, slaveDetail := range slaveDetails { 129 | slaveDetailMap, err := redis.StringMap(slaveDetail, nil) 130 | if err != nil { 131 | log.Debugf("Error getting slavedetailMap from slaveDetail: %s, err: %s", slaveDetail, err) 132 | continue 133 | } 134 | 135 | slaveFlags, ok := slaveDetailMap["flags"] 136 | if !ok { 137 | continue 138 | } 139 | if strings.Contains(slaveFlags, "o_down") { 140 | continue 141 | } 142 | if strings.Contains(slaveFlags, "s_down") { 143 | continue 144 | } 145 | masterOkSlaves = masterOkSlaves + 1 146 | } 147 | e.registerConstMetricGauge(ch, "sentinel_master_ok_slaves", float64(masterOkSlaves), labels...) 148 | } 149 | 150 | /* 151 | valid examples: 152 | 153 | master0:name=user03,status=sdown,address=192.169.2.52:6381,slaves=1,sentinels=5 154 | master1:name=user02,status=ok,address=192.169.2.54:6380,slaves=1,sentinels=5 155 | */ 156 | func parseSentinelMasterString(master string, masterInfo string) (masterName string, masterStatus string, masterAddr string, masterSlaves float64, masterSentinels float64, ok bool) { 157 | ok = false 158 | if matched, _ := regexp.MatchString(`^master\d+`, master); !matched { 159 | return 160 | } 161 | matchedMasterInfo := make(map[string]string) 162 | for _, kvPart := range strings.Split(masterInfo, ",") { 163 | x := strings.Split(kvPart, "=") 164 | if len(x) != 2 { 165 | log.Errorf("Invalid format for sentinel's master string, got: %s", kvPart) 166 | continue 167 | } 168 | matchedMasterInfo[x[0]] = x[1] 169 | } 170 | 171 | masterName = matchedMasterInfo["name"] 172 | masterStatus = matchedMasterInfo["status"] 173 | masterAddr = matchedMasterInfo["address"] 174 | masterSlaves, err := strconv.ParseFloat(matchedMasterInfo["slaves"], 64) 175 | if err != nil { 176 | log.Debugf("parseSentinelMasterString(): couldn't parse slaves value, got: %s, err: %s", matchedMasterInfo["slaves"], err) 177 | return 178 | } 179 | masterSentinels, err = strconv.ParseFloat(matchedMasterInfo["sentinels"], 64) 180 | if err != nil { 181 | log.Debugf("parseSentinelMasterString(): couldn't parse sentinels value, got: %s, err: %s", matchedMasterInfo["sentinels"], err) 182 | return 183 | } 184 | ok = true 185 | 186 | return 187 | } 188 | -------------------------------------------------------------------------------- /exporter/slowlog.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "github.com/gomodule/redigo/redis" 5 | "github.com/prometheus/client_golang/prometheus" 6 | ) 7 | 8 | func (e *Exporter) extractSlowLogMetrics(ch chan<- prometheus.Metric, c redis.Conn) { 9 | if reply, err := redis.Int64(doRedisCmd(c, "SLOWLOG", "LEN")); err == nil { 10 | e.registerConstMetricGauge(ch, "slowlog_length", float64(reply)) 11 | } 12 | 13 | values, err := redis.Values(doRedisCmd(c, "SLOWLOG", "GET", "1")) 14 | if err != nil { 15 | return 16 | } 17 | 18 | var slowlogLastID int64 19 | var lastSlowExecutionDurationSeconds float64 20 | 21 | if len(values) > 0 { 22 | if values, err = redis.Values(values[0], err); err == nil && len(values) > 0 { 23 | slowlogLastID = values[0].(int64) 24 | if len(values) > 2 { 25 | lastSlowExecutionDurationSeconds = float64(values[2].(int64)) / 1e6 26 | } 27 | } 28 | } 29 | 30 | e.registerConstMetricGauge(ch, "slowlog_last_id", float64(slowlogLastID)) 31 | e.registerConstMetricGauge(ch, "last_slow_execution_duration_seconds", lastSlowExecutionDurationSeconds) 32 | } 33 | -------------------------------------------------------------------------------- /exporter/slowlog_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/gomodule/redigo/redis" 10 | "github.com/prometheus/client_golang/prometheus" 11 | dto "github.com/prometheus/client_model/go" 12 | ) 13 | 14 | func TestSlowLog(t *testing.T) { 15 | e := getTestExporter() 16 | 17 | chM := make(chan prometheus.Metric) 18 | go func() { 19 | e.Collect(chM) 20 | close(chM) 21 | }() 22 | 23 | oldSlowLogID := float64(0) 24 | 25 | for m := range chM { 26 | switch m := m.(type) { 27 | case prometheus.Gauge: 28 | if strings.Contains(m.Desc().String(), "slowlog_last_id") { 29 | got := &dto.Metric{} 30 | m.Write(got) 31 | 32 | oldSlowLogID = got.GetGauge().GetValue() 33 | } 34 | } 35 | } 36 | 37 | setupSlowLog(t, os.Getenv("TEST_REDIS_URI")) 38 | defer resetSlowLog(t, os.Getenv("TEST_REDIS_URI")) 39 | 40 | chM = make(chan prometheus.Metric) 41 | go func() { 42 | e.Collect(chM) 43 | close(chM) 44 | }() 45 | 46 | for m := range chM { 47 | switch m := m.(type) { 48 | case prometheus.Gauge: 49 | if strings.Contains(m.Desc().String(), "slowlog_last_id") { 50 | got := &dto.Metric{} 51 | m.Write(got) 52 | 53 | val := got.GetGauge().GetValue() 54 | 55 | if oldSlowLogID > val { 56 | t.Errorf("no new slowlogs found") 57 | } 58 | } 59 | if strings.Contains(m.Desc().String(), "slowlog_length") { 60 | got := &dto.Metric{} 61 | m.Write(got) 62 | 63 | val := got.GetGauge().GetValue() 64 | if val == 0 { 65 | t.Errorf("slowlog length is zero") 66 | } 67 | } 68 | } 69 | } 70 | 71 | resetSlowLog(t, os.Getenv("TEST_REDIS_URI")) 72 | 73 | chM = make(chan prometheus.Metric) 74 | go func() { 75 | e.Collect(chM) 76 | close(chM) 77 | }() 78 | 79 | for m := range chM { 80 | switch m := m.(type) { 81 | case prometheus.Gauge: 82 | if strings.Contains(m.Desc().String(), "slowlog_length") { 83 | got := &dto.Metric{} 84 | m.Write(got) 85 | 86 | val := got.GetGauge().GetValue() 87 | if val != 0 { 88 | t.Errorf("Slowlog was not reset") 89 | } 90 | } 91 | } 92 | } 93 | } 94 | 95 | func setupSlowLog(t *testing.T, addr string) error { 96 | c, err := redis.DialURL(addr) 97 | if err != nil { 98 | t.Errorf("couldn't setup redis, err: %s ", err) 99 | return err 100 | } 101 | defer c.Close() 102 | 103 | _, err = c.Do("CONFIG", "SET", "SLOWLOG-LOG-SLOWER-THAN", 10000) 104 | if err != nil { 105 | t.Errorf("couldn't setup redis, err: %s ", err) 106 | return err 107 | } 108 | 109 | // Have to pass in the sleep time in seconds so we have to divide 110 | // the number of milliseconds by 1000 to get number of seconds 111 | _, err = c.Do("DEBUG", "SLEEP", latencyTestTimeToSleepInMillis/1000.0) 112 | if err != nil { 113 | t.Errorf("couldn't setup redis, err: %s ", err) 114 | return err 115 | } 116 | 117 | time.Sleep(time.Millisecond * 50) 118 | 119 | return nil 120 | } 121 | 122 | func resetSlowLog(t *testing.T, addr string) error { 123 | c, err := redis.DialURL(addr) 124 | if err != nil { 125 | t.Errorf("couldn't setup redis, err: %s ", err) 126 | return err 127 | } 128 | defer c.Close() 129 | 130 | _, err = c.Do("SLOWLOG", "RESET") 131 | if err != nil { 132 | t.Errorf("couldn't setup redis, err: %s ", err) 133 | return err 134 | } 135 | 136 | time.Sleep(time.Millisecond * 50) 137 | 138 | return nil 139 | } 140 | -------------------------------------------------------------------------------- /exporter/streams.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/gomodule/redigo/redis" 8 | "github.com/prometheus/client_golang/prometheus" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // All fields of the streamInfo struct must be exported 13 | // because of redis.ScanStruct (reflect) limitations 14 | type streamInfo struct { 15 | Length int64 `redis:"length"` 16 | RadixTreeKeys int64 `redis:"radix-tree-keys"` 17 | RadixTreeNodes int64 `redis:"radix-tree-nodes"` 18 | LastGeneratedId string `redis:"last-generated-id"` 19 | Groups int64 `redis:"groups"` 20 | MaxDeletedEntryId string `redis:"max-deleted-entry-id"` 21 | FirstEntryId string 22 | LastEntryId string 23 | StreamGroupsInfo []streamGroupsInfo 24 | } 25 | 26 | type streamGroupsInfo struct { 27 | Name string `redis:"name"` 28 | Consumers int64 `redis:"consumers"` 29 | Pending int64 `redis:"pending"` 30 | LastDeliveredId string `redis:"last-delivered-id"` 31 | EntriesRead int64 `redis:"entries-read"` 32 | Lag int64 `redis:"lag"` 33 | StreamGroupConsumersInfo []streamGroupConsumersInfo 34 | } 35 | 36 | type streamGroupConsumersInfo struct { 37 | Name string `redis:"name"` 38 | Pending int64 `redis:"pending"` 39 | Idle int64 `redis:"idle"` 40 | } 41 | 42 | func getStreamInfo(c redis.Conn, key string) (*streamInfo, error) { 43 | values, err := redis.Values(doRedisCmd(c, "XINFO", "STREAM", key)) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Scan slice to struct 49 | var stream streamInfo 50 | if err := redis.ScanStruct(values, &stream); err != nil { 51 | return nil, err 52 | } 53 | 54 | // Extract first and last id from slice 55 | for idx, v := range values { 56 | vbytes, ok := v.([]byte) 57 | if !ok { 58 | continue 59 | } 60 | if string(vbytes) == "first-entry" { 61 | stream.FirstEntryId = getStreamEntryId(values, idx+1) 62 | } 63 | if string(vbytes) == "last-entry" { 64 | stream.LastEntryId = getStreamEntryId(values, idx+1) 65 | } 66 | } 67 | 68 | stream.StreamGroupsInfo, err = scanStreamGroups(c, key) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | log.Debugf("getStreamInfo() stream: %#v", &stream) 74 | return &stream, nil 75 | } 76 | 77 | func getStreamEntryId(redisValue []interface{}, index int) string { 78 | if values, ok := redisValue[index].([]interface{}); !ok || len(values) < 2 { 79 | log.Debugf("Failed to parse StreamEntryId") 80 | return "" 81 | } 82 | 83 | if len(redisValue) < index || redisValue[index] == nil { 84 | log.Debugf("Failed to parse StreamEntryId") 85 | return "" 86 | } 87 | 88 | entryId, ok := redisValue[index].([]interface{})[0].([]byte) 89 | if !ok { 90 | log.Debugf("Failed to parse StreamEntryId") 91 | return "" 92 | } 93 | return string(entryId) 94 | } 95 | 96 | func scanStreamGroups(c redis.Conn, stream string) ([]streamGroupsInfo, error) { 97 | groups, err := redis.Values(doRedisCmd(c, "XINFO", "GROUPS", stream)) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | var result []streamGroupsInfo 103 | for _, g := range groups { 104 | v, err := redis.Values(g, nil) 105 | if err != nil { 106 | log.Errorf("Couldn't convert group values for stream '%s': %s", stream, err) 107 | continue 108 | } 109 | log.Debugf("streamGroupsInfo value: %#v", v) 110 | 111 | var group streamGroupsInfo 112 | if err := redis.ScanStruct(v, &group); err != nil { 113 | log.Errorf("Couldn't scan group in stream '%s': %s", stream, err) 114 | continue 115 | } 116 | 117 | group.StreamGroupConsumersInfo, err = scanStreamGroupConsumers(c, stream, group.Name) 118 | if err != nil { 119 | return nil, err 120 | } 121 | 122 | result = append(result, group) 123 | } 124 | 125 | log.Debugf("groups: %v", result) 126 | return result, nil 127 | } 128 | 129 | func scanStreamGroupConsumers(c redis.Conn, stream string, group string) ([]streamGroupConsumersInfo, error) { 130 | consumers, err := redis.Values(doRedisCmd(c, "XINFO", "CONSUMERS", stream, group)) 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | var result []streamGroupConsumersInfo 136 | for _, c := range consumers { 137 | 138 | v, err := redis.Values(c, nil) 139 | if err != nil { 140 | log.Errorf("Couldn't convert consumer values for group '%s' in stream '%s': %s", group, stream, err) 141 | continue 142 | } 143 | log.Debugf("streamGroupConsumersInfo value: %#v", v) 144 | 145 | var consumer streamGroupConsumersInfo 146 | if err := redis.ScanStruct(v, &consumer); err != nil { 147 | log.Errorf("Couldn't scan consumers for group '%s' in stream '%s': %s", group, stream, err) 148 | continue 149 | } 150 | 151 | result = append(result, consumer) 152 | } 153 | 154 | log.Debugf("consumers: %v", result) 155 | return result, nil 156 | } 157 | 158 | func parseStreamItemId(id string) float64 { 159 | if strings.TrimSpace(id) == "" { 160 | return 0 161 | } 162 | frags := strings.Split(id, "-") 163 | if len(frags) == 0 { 164 | log.Errorf("Couldn't parse StreamItemId: %s", id) 165 | return 0 166 | } 167 | parsedId, err := strconv.ParseFloat(strings.Split(id, "-")[0], 64) 168 | if err != nil { 169 | log.Errorf("Couldn't parse given StreamItemId: [%s] err: %s", id, err) 170 | } 171 | return parsedId 172 | } 173 | 174 | func (e *Exporter) extractStreamMetrics(ch chan<- prometheus.Metric, c redis.Conn) { 175 | streams, err := parseKeyArg(e.options.CheckStreams) 176 | if err != nil { 177 | log.Errorf("Couldn't parse given stream keys: %s", err) 178 | return 179 | } 180 | 181 | singleStreams, err := parseKeyArg(e.options.CheckSingleStreams) 182 | if err != nil { 183 | log.Errorf("Couldn't parse check-single-streams: %s", err) 184 | return 185 | } 186 | allStreams := append([]dbKeyPair{}, singleStreams...) 187 | 188 | scannedStreams, err := getKeysFromPatterns(c, streams, e.options.CheckKeysBatchSize) 189 | if err != nil { 190 | log.Errorf("Error expanding key patterns: %s", err) 191 | } else { 192 | allStreams = append(allStreams, scannedStreams...) 193 | } 194 | 195 | log.Debugf("allStreams: %#v", allStreams) 196 | for _, k := range allStreams { 197 | if _, err := doRedisCmd(c, "SELECT", k.db); err != nil { 198 | log.Debugf("Couldn't select database '%s' when getting stream info", k.db) 199 | continue 200 | } 201 | info, err := getStreamInfo(c, k.key) 202 | if err != nil { 203 | log.Errorf("couldn't get info for stream '%s', err: %s", k.key, err) 204 | continue 205 | } 206 | dbLabel := "db" + k.db 207 | 208 | e.registerConstMetricGauge(ch, "stream_length", float64(info.Length), dbLabel, k.key) 209 | e.registerConstMetricGauge(ch, "stream_radix_tree_keys", float64(info.RadixTreeKeys), dbLabel, k.key) 210 | e.registerConstMetricGauge(ch, "stream_radix_tree_nodes", float64(info.RadixTreeNodes), dbLabel, k.key) 211 | e.registerConstMetricGauge(ch, "stream_last_generated_id", parseStreamItemId(info.LastGeneratedId), dbLabel, k.key) 212 | e.registerConstMetricGauge(ch, "stream_groups", float64(info.Groups), dbLabel, k.key) 213 | e.registerConstMetricGauge(ch, "stream_max_deleted_entry_id", parseStreamItemId(info.MaxDeletedEntryId), dbLabel, k.key) 214 | e.registerConstMetricGauge(ch, "stream_first_entry_id", parseStreamItemId(info.FirstEntryId), dbLabel, k.key) 215 | e.registerConstMetricGauge(ch, "stream_last_entry_id", parseStreamItemId(info.LastEntryId), dbLabel, k.key) 216 | 217 | for _, g := range info.StreamGroupsInfo { 218 | e.registerConstMetricGauge(ch, "stream_group_consumers", float64(g.Consumers), dbLabel, k.key, g.Name) 219 | e.registerConstMetricGauge(ch, "stream_group_messages_pending", float64(g.Pending), dbLabel, k.key, g.Name) 220 | e.registerConstMetricGauge(ch, "stream_group_last_delivered_id", parseStreamItemId(g.LastDeliveredId), dbLabel, k.key, g.Name) 221 | e.registerConstMetricGauge(ch, "stream_group_entries_read", float64(g.EntriesRead), dbLabel, k.key, g.Name) 222 | e.registerConstMetricGauge(ch, "stream_group_lag", float64(g.Lag), dbLabel, k.key, g.Name) 223 | if !e.options.StreamsExcludeConsumerMetrics { 224 | for _, c := range g.StreamGroupConsumersInfo { 225 | e.registerConstMetricGauge(ch, "stream_group_consumer_messages_pending", float64(c.Pending), dbLabel, k.key, g.Name, c.Name) 226 | e.registerConstMetricGauge(ch, "stream_group_consumer_idle_seconds", float64(c.Idle)/1e3, dbLabel, k.key, g.Name, c.Name) 227 | } 228 | } 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /exporter/tile38.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gomodule/redigo/redis" 7 | "github.com/prometheus/client_golang/prometheus" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func (e *Exporter) extractTile38Metrics(ch chan<- prometheus.Metric, c redis.Conn) { 12 | info, err := redis.Strings(doRedisCmd(c, "SERVER", "EXT")) 13 | if err != nil { 14 | log.Errorf("extractTile38Metrics() err: %s", err) 15 | return 16 | } 17 | 18 | for i := 0; i < len(info); i += 2 { 19 | fieldKey := info[i] 20 | if !strings.HasPrefix(fieldKey, "tile38_") { 21 | fieldKey = "tile38_" + fieldKey 22 | } 23 | 24 | fieldValue := info[i+1] 25 | log.Debugf("tile38 key:%s val:%s", fieldKey, fieldValue) 26 | 27 | if !e.includeMetric(fieldKey) { 28 | continue 29 | } 30 | 31 | e.parseAndRegisterConstMetric(ch, fieldKey, fieldValue) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /exporter/tile38_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | func TestTile38(t *testing.T) { 12 | if os.Getenv("TEST_TILE38_URI") == "" { 13 | t.Skipf("TEST_TILE38_URI not set - skipping") 14 | } 15 | 16 | tsts := []struct { 17 | addr string 18 | isTile38 bool 19 | wantTile38Metrics bool 20 | }{ 21 | {addr: os.Getenv("TEST_TILE38_URI"), isTile38: true, wantTile38Metrics: true}, 22 | {addr: os.Getenv("TEST_TILE38_URI"), isTile38: false, wantTile38Metrics: false}, 23 | {addr: os.Getenv("TEST_REDIS_URI"), isTile38: true, wantTile38Metrics: false}, 24 | {addr: os.Getenv("TEST_REDIS_URI"), isTile38: false, wantTile38Metrics: false}, 25 | } 26 | 27 | for _, tst := range tsts { 28 | e, _ := NewRedisExporter(tst.addr, Options{Namespace: "test", IsTile38: tst.isTile38}) 29 | 30 | chM := make(chan prometheus.Metric) 31 | go func() { 32 | e.Collect(chM) 33 | close(chM) 34 | }() 35 | 36 | wantedMetrics := map[string]bool{ 37 | "tile38_threads_total": false, 38 | "tile38_cpus_total": false, 39 | "tile38_go_goroutines_total": false, 40 | "tile38_avg_item_size_bytes": false, 41 | } 42 | 43 | for m := range chM { 44 | for want := range wantedMetrics { 45 | if strings.Contains(m.Desc().String(), want) { 46 | wantedMetrics[want] = true 47 | } 48 | } 49 | } 50 | 51 | if tst.wantTile38Metrics { 52 | for want, found := range wantedMetrics { 53 | if !found { 54 | t.Errorf("%s was *not* found in tile38 metrics but expected", want) 55 | } 56 | } 57 | } else if !tst.wantTile38Metrics { 58 | for want, found := range wantedMetrics { 59 | if found { 60 | t.Errorf("%s was *found* in tile38 metrics but *not* expected", want) 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /exporter/tls.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "os" 8 | 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // CreateClientTLSConfig verifies configured files and return a prepared tls.Config 13 | func (e *Exporter) CreateClientTLSConfig() (*tls.Config, error) { 14 | tlsConfig := tls.Config{ 15 | InsecureSkipVerify: e.options.SkipTLSVerification, 16 | } 17 | 18 | if e.options.ClientCertFile != "" && e.options.ClientKeyFile != "" { 19 | cert, err := LoadKeyPair(e.options.ClientCertFile, e.options.ClientKeyFile) 20 | if err != nil { 21 | return nil, err 22 | } 23 | tlsConfig.Certificates = []tls.Certificate{*cert} 24 | } 25 | 26 | if e.options.CaCertFile != "" { 27 | certificates, err := LoadCAFile(e.options.CaCertFile) 28 | if err != nil { 29 | return nil, err 30 | } 31 | tlsConfig.RootCAs = certificates 32 | } else { 33 | // Load the system certificate pool 34 | rootCAs, err := x509.SystemCertPool() 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | tlsConfig.RootCAs = rootCAs 40 | } 41 | 42 | return &tlsConfig, nil 43 | } 44 | 45 | var tlsVersions = map[string]uint16{ 46 | "TLS1.3": tls.VersionTLS13, 47 | "TLS1.2": tls.VersionTLS12, 48 | "TLS1.1": tls.VersionTLS11, 49 | "TLS1.0": tls.VersionTLS10, 50 | } 51 | 52 | // CreateServerTLSConfig verifies configuration and return a prepared tls.Config 53 | func (e *Exporter) CreateServerTLSConfig(certFile, keyFile, caCertFile, minVersionString string) (*tls.Config, error) { 54 | // Verify that the initial key pair is accepted 55 | _, err := LoadKeyPair(certFile, keyFile) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | // Get minimum acceptable TLS version from the config string 61 | minVersion, ok := tlsVersions[minVersionString] 62 | if !ok { 63 | return nil, fmt.Errorf("configured minimum TLS version unknown: '%s'", minVersionString) 64 | } 65 | 66 | tlsConfig := tls.Config{ 67 | MinVersion: minVersion, 68 | GetCertificate: GetServerCertificateFunc(certFile, keyFile), 69 | } 70 | 71 | if caCertFile != "" { 72 | // Verify that the initial CA file is accepted when configured 73 | _, err := LoadCAFile(caCertFile) 74 | if err != nil { 75 | return nil, err 76 | } 77 | tlsConfig.GetConfigForClient = GetConfigForClientFunc(certFile, keyFile, caCertFile) 78 | } 79 | 80 | return &tlsConfig, nil 81 | } 82 | 83 | // GetServerCertificateFunc returns a function for tls.Config.GetCertificate 84 | func GetServerCertificateFunc(certFile, keyFile string) func(*tls.ClientHelloInfo) (*tls.Certificate, error) { 85 | return func(*tls.ClientHelloInfo) (*tls.Certificate, error) { 86 | return LoadKeyPair(certFile, keyFile) 87 | } 88 | } 89 | 90 | // GetConfigForClientFunc returns a function for tls.Config.GetConfigForClient 91 | func GetConfigForClientFunc(certFile, keyFile, caCertFile string) func(*tls.ClientHelloInfo) (*tls.Config, error) { 92 | return func(*tls.ClientHelloInfo) (*tls.Config, error) { 93 | certificates, err := LoadCAFile(caCertFile) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | tlsConfig := tls.Config{ 99 | ClientAuth: tls.RequireAndVerifyClientCert, 100 | ClientCAs: certificates, 101 | GetCertificate: GetServerCertificateFunc(certFile, keyFile), 102 | } 103 | return &tlsConfig, nil 104 | } 105 | } 106 | 107 | // LoadKeyPair reads and parses a public/private key pair from a pair of files. 108 | // The files must contain PEM encoded data. 109 | func LoadKeyPair(certFile, keyFile string) (*tls.Certificate, error) { 110 | log.Debugf("Load key pair: %s %s", certFile, keyFile) 111 | cert, err := tls.LoadX509KeyPair(certFile, keyFile) 112 | if err != nil { 113 | return nil, err 114 | } 115 | return &cert, nil 116 | } 117 | 118 | // LoadCAFile reads and parses CA certificates from a file into a pool. 119 | // The file must contain PEM encoded data. 120 | func LoadCAFile(caFile string) (*x509.CertPool, error) { 121 | log.Debugf("Load CA cert file: %s", caFile) 122 | pemCerts, err := os.ReadFile(caFile) 123 | if err != nil { 124 | return nil, err 125 | } 126 | pool := x509.NewCertPool() 127 | pool.AppendCertsFromPEM(pemCerts) 128 | return pool, nil 129 | } 130 | -------------------------------------------------------------------------------- /exporter/tls_test.go: -------------------------------------------------------------------------------- 1 | package exporter 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | ) 10 | 11 | func TestCreateClientTLSConfig(t *testing.T) { 12 | for _, test := range []struct { 13 | name string 14 | options Options 15 | expectSuccess bool 16 | }{ 17 | // positive tests 18 | {"no_options", Options{}, true}, 19 | {"skip_verificaton", Options{ 20 | SkipTLSVerification: true}, true}, 21 | {"load_client_keypair", Options{ 22 | ClientCertFile: "../contrib/tls/redis.crt", 23 | ClientKeyFile: "../contrib/tls/redis.key"}, true}, 24 | {"load_ca_cert", Options{ 25 | CaCertFile: "../contrib/tls/ca.crt"}, true}, 26 | {"load_system_certs", Options{}, true}, 27 | 28 | // negative tests 29 | {"nonexisting_client_files", Options{ 30 | ClientCertFile: "/nonexisting/file", 31 | ClientKeyFile: "/nonexisting/file"}, false}, 32 | {"nonexisting_ca_file", Options{ 33 | CaCertFile: "/nonexisting/file"}, false}, 34 | } { 35 | t.Run(test.name, func(t *testing.T) { 36 | e := getTestExporterWithOptions(test.options) 37 | 38 | _, err := e.CreateClientTLSConfig() 39 | if test.expectSuccess && err != nil { 40 | t.Errorf("Expected success for test: %s, got err: %s", test.name, err) 41 | return 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func TestValkeyTLSScheme(t *testing.T) { 48 | for _, host := range []string{ 49 | os.Getenv("TEST_REDIS7_TLS_URI"), 50 | os.Getenv("TEST_VALKEY8_TLS_URI"), 51 | } { 52 | t.Run(host, func(t *testing.T) { 53 | 54 | e, _ := NewRedisExporter(host, 55 | Options{ 56 | SkipTLSVerification: true, 57 | ClientCertFile: "../contrib/tls/redis.crt", 58 | ClientKeyFile: "../contrib/tls/redis.key", 59 | }, 60 | ) 61 | c, err := e.connectToRedis() 62 | if err != nil { 63 | t.Fatalf("connectToRedis() err: %s", err) 64 | } 65 | 66 | if _, err := c.Do("PING", ""); err != nil { 67 | t.Errorf("PING err: %s", err) 68 | } 69 | 70 | c.Close() 71 | 72 | chM := make(chan prometheus.Metric) 73 | go func() { 74 | e.Collect(chM) 75 | close(chM) 76 | }() 77 | 78 | tsts := []struct { 79 | in string 80 | found bool 81 | }{ 82 | {in: "db_keys"}, 83 | {in: "commands_total"}, 84 | {in: "total_connections_received"}, 85 | {in: "used_memory"}, 86 | } 87 | for m := range chM { 88 | desc := m.Desc().String() 89 | for i := range tsts { 90 | if strings.Contains(desc, tsts[i].in) { 91 | tsts[i].found = true 92 | } 93 | } 94 | } 95 | 96 | }) 97 | } 98 | } 99 | 100 | func TestCreateServerTLSConfig(t *testing.T) { 101 | e := getTestExporter() 102 | 103 | // positive tests 104 | _, err := e.CreateServerTLSConfig("../contrib/tls/redis.crt", "../contrib/tls/redis.key", "", "TLS1.1") 105 | if err != nil { 106 | t.Errorf("CreateServerTLSConfig() err: %s", err) 107 | } 108 | _, err = e.CreateServerTLSConfig("../contrib/tls/redis.crt", "../contrib/tls/redis.key", "../contrib/tls/ca.crt", "TLS1.0") 109 | if err != nil { 110 | t.Errorf("CreateServerTLSConfig() err: %s", err) 111 | } 112 | 113 | // negative tests 114 | _, err = e.CreateServerTLSConfig("/nonexisting/file", "/nonexisting/file", "", "TLS1.1") 115 | if err == nil { 116 | t.Errorf("Expected CreateServerTLSConfig() to fail") 117 | } 118 | _, err = e.CreateServerTLSConfig("/nonexisting/file", "/nonexisting/file", "/nonexisting/file", "TLS1.2") 119 | if err == nil { 120 | t.Errorf("Expected CreateServerTLSConfig() to fail") 121 | } 122 | _, err = e.CreateServerTLSConfig("../contrib/tls/redis.crt", "../contrib/tls/redis.key", "/nonexisting/file", "TLS1.3") 123 | if err == nil { 124 | t.Errorf("Expected CreateServerTLSConfig() to fail") 125 | } 126 | _, err = e.CreateServerTLSConfig("../contrib/tls/redis.crt", "../contrib/tls/redis.key", "../contrib/tls/ca.crt", "TLSX") 127 | if err == nil { 128 | t.Errorf("Expected CreateServerTLSConfig() to fail") 129 | } 130 | } 131 | 132 | func TestGetServerCertificateFunc(t *testing.T) { 133 | // positive test 134 | _, err := GetServerCertificateFunc("../contrib/tls/ca.crt", "../contrib/tls/ca.key")(nil) 135 | if err != nil { 136 | t.Errorf("GetServerCertificateFunc() err: %s", err) 137 | } 138 | 139 | // negative test 140 | _, err = GetServerCertificateFunc("/nonexisting/file", "/nonexisting/file")(nil) 141 | if err == nil { 142 | t.Errorf("Expected GetServerCertificateFunc() to fail") 143 | } 144 | } 145 | 146 | func TestGetConfigForClientFunc(t *testing.T) { 147 | // positive test 148 | _, err := GetConfigForClientFunc("../contrib/tls/redis.crt", "../contrib/tls/redis.key", "../contrib/tls/ca.crt")(nil) 149 | if err != nil { 150 | t.Errorf("GetConfigForClientFunc() err: %s", err) 151 | } 152 | 153 | // negative test 154 | _, err = GetConfigForClientFunc("/nonexisting/file", "/nonexisting/file", "/nonexisting/file")(nil) 155 | if err == nil { 156 | t.Errorf("Expected GetConfigForClientFunc() to fail") 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/oliver006/redis_exporter 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/gomodule/redigo v1.9.2 9 | github.com/mna/redisc v1.4.0 10 | github.com/prometheus/client_golang v1.22.0 11 | github.com/prometheus/client_model v0.6.2 12 | github.com/sirupsen/logrus v1.9.3 13 | ) 14 | 15 | require ( 16 | github.com/beorn7/perks v1.0.1 // indirect 17 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 18 | github.com/klauspost/compress v1.18.0 // indirect 19 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 20 | github.com/prometheus/common v0.62.0 // indirect 21 | github.com/prometheus/procfs v0.15.1 // indirect 22 | golang.org/x/sys v0.30.0 // indirect 23 | google.golang.org/protobuf v1.36.6 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/gomodule/redigo v1.8.5/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= 9 | github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s= 10 | github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= 11 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 12 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 13 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 14 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 15 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 16 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 17 | github.com/mna/redisc v1.4.0 h1:rBKXyGO/39SGmYoRKCyzXcBpoMMKqkikg8E1G8YIfSA= 18 | github.com/mna/redisc v1.4.0/go.mod h1:CplIoaSTDi5h9icnj4FLbRgHoNKCHDNJDVRztWDGeSQ= 19 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 24 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 25 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 26 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 27 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 28 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 29 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 30 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 31 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 32 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 33 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 34 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 35 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 36 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 37 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 40 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 41 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 42 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 44 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 45 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 46 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 47 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 48 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "runtime" 11 | "strconv" 12 | "strings" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/prometheus/client_golang/prometheus" 17 | "github.com/prometheus/client_golang/prometheus/collectors" 18 | log "github.com/sirupsen/logrus" 19 | 20 | "github.com/oliver006/redis_exporter/exporter" 21 | ) 22 | 23 | var ( 24 | /* 25 | BuildVersion, BuildDate, BuildCommitSha are filled in by the build script 26 | */ 27 | BuildVersion = "<<< filled in by build >>>" 28 | BuildDate = "<<< filled in by build >>>" 29 | BuildCommitSha = "<<< filled in by build >>>" 30 | ) 31 | 32 | func getEnv(key string, defaultVal string) string { 33 | if envVal, ok := os.LookupEnv(key); ok { 34 | return envVal 35 | } 36 | return defaultVal 37 | } 38 | 39 | func getEnvBool(key string, defaultVal bool) bool { 40 | if envVal, ok := os.LookupEnv(key); ok { 41 | envBool, err := strconv.ParseBool(envVal) 42 | if err == nil { 43 | return envBool 44 | } 45 | } 46 | return defaultVal 47 | } 48 | 49 | func getEnvInt64(key string, defaultVal int64) int64 { 50 | if envVal, ok := os.LookupEnv(key); ok { 51 | envInt64, err := strconv.ParseInt(envVal, 10, 64) 52 | if err == nil { 53 | return envInt64 54 | } 55 | } 56 | return defaultVal 57 | } 58 | 59 | func main() { 60 | var ( 61 | redisAddr = flag.String("redis.addr", getEnv("REDIS_ADDR", "redis://localhost:6379"), "Address of the Redis instance to scrape") 62 | redisUser = flag.String("redis.user", getEnv("REDIS_USER", ""), "User name to use for authentication (Redis ACL for Redis 6.0 and newer)") 63 | redisPwd = flag.String("redis.password", getEnv("REDIS_PASSWORD", ""), "Password of the Redis instance to scrape") 64 | redisPwdFile = flag.String("redis.password-file", getEnv("REDIS_PASSWORD_FILE", ""), "Password file of the Redis instance to scrape") 65 | namespace = flag.String("namespace", getEnv("REDIS_EXPORTER_NAMESPACE", "redis"), "Namespace for metrics") 66 | checkKeys = flag.String("check-keys", getEnv("REDIS_EXPORTER_CHECK_KEYS", ""), "Comma separated list of key-patterns to export value and length/size, searched for with SCAN") 67 | checkSingleKeys = flag.String("check-single-keys", getEnv("REDIS_EXPORTER_CHECK_SINGLE_KEYS", ""), "Comma separated list of single keys to export value and length/size") 68 | checkKeyGroups = flag.String("check-key-groups", getEnv("REDIS_EXPORTER_CHECK_KEY_GROUPS", ""), "Comma separated list of lua regex for grouping keys") 69 | checkStreams = flag.String("check-streams", getEnv("REDIS_EXPORTER_CHECK_STREAMS", ""), "Comma separated list of stream-patterns to export info about streams, groups and consumers, searched for with SCAN") 70 | checkSingleStreams = flag.String("check-single-streams", getEnv("REDIS_EXPORTER_CHECK_SINGLE_STREAMS", ""), "Comma separated list of single streams to export info about streams, groups and consumers") 71 | streamsExcludeConsumerMetrics = flag.Bool("streams-exclude-consumer-metrics", getEnvBool("REDIS_EXPORTER_STREAMS_EXCLUDE_CONSUMER_METRICS", false), "Don't collect per consumer metrics for streams (decreases cardinality)") 72 | countKeys = flag.String("count-keys", getEnv("REDIS_EXPORTER_COUNT_KEYS", ""), "Comma separated list of patterns to count (eg: 'db0=production_*,db3=sessions:*'), searched for with SCAN") 73 | checkKeysBatchSize = flag.Int64("check-keys-batch-size", getEnvInt64("REDIS_EXPORTER_CHECK_KEYS_BATCH_SIZE", 1000), "Approximate number of keys to process in each execution, larger value speeds up scanning.\nWARNING: Still Redis is a single-threaded app, huge COUNT can affect production environment.") 74 | scriptPath = flag.String("script", getEnv("REDIS_EXPORTER_SCRIPT", ""), "Comma separated list of path(s) to Redis Lua script(s) for gathering extra metrics") 75 | listenAddress = flag.String("web.listen-address", getEnv("REDIS_EXPORTER_WEB_LISTEN_ADDRESS", ":9121"), "Address to listen on for web interface and telemetry.") 76 | metricPath = flag.String("web.telemetry-path", getEnv("REDIS_EXPORTER_WEB_TELEMETRY_PATH", "/metrics"), "Path under which to expose metrics.") 77 | logFormat = flag.String("log-format", getEnv("REDIS_EXPORTER_LOG_FORMAT", "txt"), "Log format, valid options are txt and json") 78 | configCommand = flag.String("config-command", getEnv("REDIS_EXPORTER_CONFIG_COMMAND", "CONFIG"), "What to use for the CONFIG command, set to \"-\" to skip config metrics extraction") 79 | connectionTimeout = flag.String("connection-timeout", getEnv("REDIS_EXPORTER_CONNECTION_TIMEOUT", "15s"), "Timeout for connection to Redis instance") 80 | tlsClientKeyFile = flag.String("tls-client-key-file", getEnv("REDIS_EXPORTER_TLS_CLIENT_KEY_FILE", ""), "Name of the client key file (including full path) if the server requires TLS client authentication") 81 | tlsClientCertFile = flag.String("tls-client-cert-file", getEnv("REDIS_EXPORTER_TLS_CLIENT_CERT_FILE", ""), "Name of the client certificate file (including full path) if the server requires TLS client authentication") 82 | tlsCaCertFile = flag.String("tls-ca-cert-file", getEnv("REDIS_EXPORTER_TLS_CA_CERT_FILE", ""), "Name of the CA certificate file (including full path) if the server requires TLS client authentication") 83 | tlsServerKeyFile = flag.String("tls-server-key-file", getEnv("REDIS_EXPORTER_TLS_SERVER_KEY_FILE", ""), "Name of the server key file (including full path) if the web interface and telemetry should use TLS") 84 | tlsServerCertFile = flag.String("tls-server-cert-file", getEnv("REDIS_EXPORTER_TLS_SERVER_CERT_FILE", ""), "Name of the server certificate file (including full path) if the web interface and telemetry should use TLS") 85 | tlsServerCaCertFile = flag.String("tls-server-ca-cert-file", getEnv("REDIS_EXPORTER_TLS_SERVER_CA_CERT_FILE", ""), "Name of the CA certificate file (including full path) if the web interface and telemetry should require TLS client authentication") 86 | tlsServerMinVersion = flag.String("tls-server-min-version", getEnv("REDIS_EXPORTER_TLS_SERVER_MIN_VERSION", "TLS1.2"), "Minimum TLS version that is acceptable by the web interface and telemetry when using TLS") 87 | maxDistinctKeyGroups = flag.Int64("max-distinct-key-groups", getEnvInt64("REDIS_EXPORTER_MAX_DISTINCT_KEY_GROUPS", 100), "The maximum number of distinct key groups with the most memory utilization to present as distinct metrics per database, the leftover key groups will be aggregated in the 'overflow' bucket") 88 | isDebug = flag.Bool("debug", getEnvBool("REDIS_EXPORTER_DEBUG", false), "Output verbose debug information") 89 | setClientName = flag.Bool("set-client-name", getEnvBool("REDIS_EXPORTER_SET_CLIENT_NAME", true), "Whether to set client name to redis_exporter") 90 | isTile38 = flag.Bool("is-tile38", getEnvBool("REDIS_EXPORTER_IS_TILE38", false), "Whether to scrape Tile38 specific metrics") 91 | isCluster = flag.Bool("is-cluster", getEnvBool("REDIS_EXPORTER_IS_CLUSTER", false), "Whether this is a redis cluster (Enable this if you need to fetch key level data on a Redis Cluster).") 92 | exportClientList = flag.Bool("export-client-list", getEnvBool("REDIS_EXPORTER_EXPORT_CLIENT_LIST", false), "Whether to scrape Client List specific metrics") 93 | exportClientPort = flag.Bool("export-client-port", getEnvBool("REDIS_EXPORTER_EXPORT_CLIENT_PORT", false), "Whether to include the client's port when exporting the client list. Warning: including the port increases the number of metrics generated and will make your Prometheus server take up more memory") 94 | showVersion = flag.Bool("version", false, "Show version information and exit") 95 | redisMetricsOnly = flag.Bool("redis-only-metrics", getEnvBool("REDIS_EXPORTER_REDIS_ONLY_METRICS", false), "Whether to also export go runtime metrics") 96 | pingOnConnect = flag.Bool("ping-on-connect", getEnvBool("REDIS_EXPORTER_PING_ON_CONNECT", false), "Whether to ping the redis instance after connecting") 97 | inclConfigMetrics = flag.Bool("include-config-metrics", getEnvBool("REDIS_EXPORTER_INCL_CONFIG_METRICS", false), "Whether to include all config settings as metrics") 98 | inclModulesMetrics = flag.Bool("include-modules-metrics", getEnvBool("REDIS_EXPORTER_INCL_MODULES_METRICS", false), "Whether to collect Redis Modules metrics") 99 | disableExportingKeyValues = flag.Bool("disable-exporting-key-values", getEnvBool("REDIS_EXPORTER_DISABLE_EXPORTING_KEY_VALUES", false), "Whether to disable values of keys stored in redis as labels or not when using check-keys/check-single-key") 100 | excludeLatencyHistogramMetrics = flag.Bool("exclude-latency-histogram-metrics", getEnvBool("REDIS_EXPORTER_EXCLUDE_LATENCY_HISTOGRAM_METRICS", false), "Do not try to collect latency histogram metrics") 101 | redactConfigMetrics = flag.Bool("redact-config-metrics", getEnvBool("REDIS_EXPORTER_REDACT_CONFIG_METRICS", true), "Whether to redact config settings that include potentially sensitive information like passwords") 102 | inclSystemMetrics = flag.Bool("include-system-metrics", getEnvBool("REDIS_EXPORTER_INCL_SYSTEM_METRICS", false), "Whether to include system metrics like e.g. redis_total_system_memory_bytes") 103 | skipTLSVerification = flag.Bool("skip-tls-verification", getEnvBool("REDIS_EXPORTER_SKIP_TLS_VERIFICATION", false), "Whether to to skip TLS verification") 104 | skipCheckKeysForRoleMaster = flag.Bool("skip-checkkeys-for-role-master", getEnvBool("REDIS_EXPORTER_SKIP_CHECKKEYS_FOR_ROLE_MASTER", false), "Whether to skip gathering the check-keys metrics (size, val) when the instance is of type master (reduce load on master nodes)") 105 | basicAuthUsername = flag.String("basic-auth-username", getEnv("REDIS_EXPORTER_BASIC_AUTH_USERNAME", ""), "Username for basic authentication") 106 | basicAuthPassword = flag.String("basic-auth-password", getEnv("REDIS_EXPORTER_BASIC_AUTH_PASSWORD", ""), "Password for basic authentication") 107 | inclMetricsForEmptyDatabases = flag.Bool("include-metrics-for-empty-databases", getEnvBool("REDIS_EXPORTER_INCL_METRICS_FOR_EMPTY_DATABASES", true), "Whether to emit db metrics (like db_keys) for empty databases") 108 | ) 109 | flag.Parse() 110 | 111 | switch *logFormat { 112 | case "json": 113 | log.SetFormatter(&log.JSONFormatter{}) 114 | default: 115 | log.SetFormatter(&log.TextFormatter{}) 116 | } 117 | if *showVersion { 118 | log.SetOutput(os.Stdout) 119 | } 120 | log.Printf("Redis Metrics Exporter %s build date: %s sha1: %s Go: %s GOOS: %s GOARCH: %s", 121 | BuildVersion, BuildDate, BuildCommitSha, 122 | runtime.Version(), 123 | runtime.GOOS, 124 | runtime.GOARCH, 125 | ) 126 | if *showVersion { 127 | return 128 | } 129 | if *isDebug { 130 | log.SetLevel(log.DebugLevel) 131 | log.Debugln("Enabling debug output") 132 | } else { 133 | log.SetLevel(log.InfoLevel) 134 | } 135 | 136 | to, err := time.ParseDuration(*connectionTimeout) 137 | if err != nil { 138 | log.Fatalf("Couldn't parse connection timeout duration, err: %s", err) 139 | } 140 | 141 | passwordMap := make(map[string]string) 142 | if *redisPwd == "" && *redisPwdFile != "" { 143 | passwordMap, err = exporter.LoadPwdFile(*redisPwdFile) 144 | if err != nil { 145 | log.Fatalf("Error loading redis passwords from file %s, err: %s", *redisPwdFile, err) 146 | } 147 | } 148 | 149 | var ls map[string][]byte 150 | if *scriptPath != "" { 151 | scripts := strings.Split(*scriptPath, ",") 152 | ls = make(map[string][]byte, len(scripts)) 153 | for _, script := range scripts { 154 | if ls[script], err = os.ReadFile(script); err != nil { 155 | log.Fatalf("Error loading script file %s err: %s", script, err) 156 | } 157 | } 158 | } 159 | 160 | registry := prometheus.NewRegistry() 161 | if !*redisMetricsOnly { 162 | registry.MustRegister( 163 | // expose process metrics like CPU, Memory, file descriptor usage etc. 164 | collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), 165 | // expose all Go runtime metrics like GC stats, memory stats etc. 166 | collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll)), 167 | ) 168 | } 169 | 170 | exp, err := exporter.NewRedisExporter( 171 | *redisAddr, 172 | exporter.Options{ 173 | User: *redisUser, 174 | Password: *redisPwd, 175 | PasswordMap: passwordMap, 176 | Namespace: *namespace, 177 | ConfigCommandName: *configCommand, 178 | CheckKeys: *checkKeys, 179 | CheckSingleKeys: *checkSingleKeys, 180 | CheckKeysBatchSize: *checkKeysBatchSize, 181 | CheckKeyGroups: *checkKeyGroups, 182 | MaxDistinctKeyGroups: *maxDistinctKeyGroups, 183 | CheckStreams: *checkStreams, 184 | CheckSingleStreams: *checkSingleStreams, 185 | StreamsExcludeConsumerMetrics: *streamsExcludeConsumerMetrics, 186 | CountKeys: *countKeys, 187 | LuaScript: ls, 188 | InclSystemMetrics: *inclSystemMetrics, 189 | InclConfigMetrics: *inclConfigMetrics, 190 | DisableExportingKeyValues: *disableExportingKeyValues, 191 | ExcludeLatencyHistogramMetrics: *excludeLatencyHistogramMetrics, 192 | RedactConfigMetrics: *redactConfigMetrics, 193 | SetClientName: *setClientName, 194 | IsTile38: *isTile38, 195 | IsCluster: *isCluster, 196 | InclModulesMetrics: *inclModulesMetrics, 197 | ExportClientList: *exportClientList, 198 | ExportClientsInclPort: *exportClientPort, 199 | SkipCheckKeysForRoleMaster: *skipCheckKeysForRoleMaster, 200 | SkipTLSVerification: *skipTLSVerification, 201 | ClientCertFile: *tlsClientCertFile, 202 | ClientKeyFile: *tlsClientKeyFile, 203 | CaCertFile: *tlsCaCertFile, 204 | ConnectionTimeouts: to, 205 | MetricsPath: *metricPath, 206 | RedisMetricsOnly: *redisMetricsOnly, 207 | PingOnConnect: *pingOnConnect, 208 | RedisPwdFile: *redisPwdFile, 209 | Registry: registry, 210 | BuildInfo: exporter.BuildInfo{ 211 | Version: BuildVersion, 212 | CommitSha: BuildCommitSha, 213 | Date: BuildDate, 214 | }, 215 | BasicAuthUsername: *basicAuthUsername, 216 | BasicAuthPassword: *basicAuthPassword, 217 | InclMetricsForEmptyDatabases: *inclMetricsForEmptyDatabases, 218 | }, 219 | ) 220 | if err != nil { 221 | log.Fatal(err) 222 | } 223 | 224 | // Verify that initial client keypair and CA are accepted 225 | if (*tlsClientCertFile != "") != (*tlsClientKeyFile != "") { 226 | log.Fatal("TLS client key file and cert file should both be present") 227 | } 228 | _, err = exp.CreateClientTLSConfig() 229 | if err != nil { 230 | log.Fatal(err) 231 | } 232 | 233 | log.Infof("Providing metrics at %s%s", *listenAddress, *metricPath) 234 | log.Debugf("Configured redis addr: %#v", *redisAddr) 235 | server := &http.Server{ 236 | Addr: *listenAddress, 237 | Handler: exp, 238 | } 239 | go func() { 240 | if *tlsServerCertFile != "" && *tlsServerKeyFile != "" { 241 | log.Debugf("Bind as TLS using cert %s and key %s", *tlsServerCertFile, *tlsServerKeyFile) 242 | 243 | tlsConfig, err := exp.CreateServerTLSConfig(*tlsServerCertFile, *tlsServerKeyFile, *tlsServerCaCertFile, *tlsServerMinVersion) 244 | if err != nil { 245 | log.Fatal(err) 246 | } 247 | server.TLSConfig = tlsConfig 248 | if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { 249 | log.Fatalf("TLS Server error: %v", err) 250 | } 251 | } else { 252 | if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 253 | log.Fatalf("Server error: %v", err) 254 | } 255 | } 256 | }() 257 | 258 | // graceful shutdown 259 | quit := make(chan os.Signal, 1) 260 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 261 | _quit := <-quit 262 | log.Infof("Received %s signal, exiting", _quit.String()) 263 | // Create a context with a timeout 264 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 265 | defer cancel() 266 | 267 | // Shutdown the HTTP server gracefully 268 | if err := server.Shutdown(ctx); err != nil { 269 | log.Fatalf("Server shutdown failed: %v", err) 270 | } 271 | log.Infof("Server shut down gracefully") 272 | } 273 | -------------------------------------------------------------------------------- /package-github-binaries.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -u -e -o pipefail 4 | 5 | mkdir -p dist 6 | 7 | for build in $(ls .build); do 8 | echo "Creating archive for ${build}" 9 | 10 | cp LICENSE README.md ".build/${build}/" 11 | 12 | if [[ "${build}" =~ windows-.*$ ]] ; then 13 | 14 | # Make sure to clear out zip files to prevent zip from appending to the archive. 15 | rm "dist/${build}.zip" || true 16 | cd ".build/" && zip -r --quiet -9 "../dist/${build}.zip" "${build}" && cd ../ 17 | else 18 | tar -C ".build/" -czf "dist/${build}.tar.gz" "${build}" 19 | fi 20 | done 21 | 22 | cd dist 23 | sha256sum *.gz *.zip > sha256sums.txt 24 | ls -la 25 | cd .. 26 | 27 | --------------------------------------------------------------------------------