├── .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 |
--------------------------------------------------------------------------------