├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── change-request.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── main.yaml │ ├── pr-actions.yaml │ ├── pr-build.yaml │ ├── pr-dependency-review.yaml │ ├── pr-label.yaml │ ├── pr-nancy.yaml │ ├── pr-stale.yaml │ ├── pr-trivy.yaml │ ├── rebase.yaml │ ├── release.yaml │ ├── report-on-vulnerabilities.yaml │ ├── scan.yaml │ └── scorecard.yaml ├── .gitignore ├── .goreleaser.yaml ├── .nancy-ignore ├── .pre-commit-config.yaml ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── artifacthub-repo.yml ├── chart └── mongodb-query-exporter │ ├── Chart.yaml │ ├── README.md │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── podmonitor.yaml │ ├── prometheusrule.yaml │ ├── secret.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── servicemonitor.yaml │ └── values.yaml ├── cmd ├── integration_test.go └── main.go ├── config ├── base │ ├── deployment.yaml │ ├── kustomization.yaml │ └── service.yaml └── tests │ ├── base │ ├── default │ │ ├── kustomization.yaml │ │ └── namespace.yaml │ └── mongodb │ │ ├── kustomization.yaml │ │ └── root-secret.yaml │ └── cases │ └── mongodb-v5 │ ├── configmap.yaml │ ├── kustomization.yaml │ └── verify-get-metrics.yaml ├── example ├── configv1.yaml ├── configv2.yaml └── configv3.yaml ├── go.mod ├── go.sum ├── internal ├── collector │ ├── collector.go │ ├── collector_test.go │ ├── logger.go │ ├── mongodb.go │ └── mongodb_test.go ├── config │ ├── config.go │ ├── v1 │ │ ├── config.go │ │ └── config_test.go │ ├── v2 │ │ ├── config.go │ │ └── config_test.go │ └── v3 │ │ ├── config.go │ │ └── config_test.go └── x │ └── zap │ └── zap.go └── renovate.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | #### To Reproduce 14 | Steps to reproduce the behavior: 15 | 16 | #### Expected behavior 17 | A clear and concise description of what you expected to happen. 18 | 19 | #### Environment 20 | - mongodb-query-exporter version: [e.g. v1.0.0] 21 | - prometheus version: [e.g. v1.0.0] 22 | - MongoDB version: [e.g. v5.0.0] 23 | - Deployed as: [e.g. docker,kubernetes,binary] 24 | 25 | #### Additional context 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/change-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Change request 3 | about: Propose a change for an already implemented solution 4 | title: '' 5 | labels: change 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Describe the change 11 | A clear and concise description of what the change is about. 12 | 13 | #### Current situation 14 | Describe the current situation. 15 | 16 | #### Should 17 | Describe the changes you would like to propose. 18 | 19 | #### Additional context 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Is your feature request related to a problem? Please describe 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | #### Describe the solution you'd like 14 | A clear and concise description of what you want to happen. 15 | 16 | #### Describe alternatives you've considered 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | #### Additional context 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Current situation 2 | 3 | 4 | ## Proposal 5 | 6 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Harden Runner 15 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 16 | with: 17 | egress-policy: audit 18 | 19 | - name: Checkout 20 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 21 | - name: Setup Go 22 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 23 | with: 24 | go-version: 1.20.5 25 | - name: Tests 26 | run: make test 27 | - name: Send go coverage report 28 | uses: shogo82148/actions-goveralls@7b1bd2871942af030d707d6574e5f684f9891fb2 # v1.8.0 29 | with: 30 | path-to-profile: coverage.out 31 | -------------------------------------------------------------------------------- /.github/workflows/pr-actions.yaml: -------------------------------------------------------------------------------- 1 | name: pr-actions 2 | 3 | permissions: {} 4 | 5 | on: 6 | pull_request: 7 | branches: 8 | - 'master' 9 | 10 | jobs: 11 | ensure-sha-pinned: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Harden Runner 15 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 16 | with: 17 | egress-policy: audit 18 | 19 | - name: Checkout 20 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 21 | - name: Ensure SHA pinned actions 22 | uses: zgosalvez/github-actions-ensure-sha-pinned-actions@99589360fda82ecfac331cc6bfc9d7d74487359c # v2.1.6 23 | with: 24 | # slsa-github-generator requires using a semver tag for reusable workflows. 25 | # See: https://github.com/slsa-framework/slsa-github-generator#referencing-slsa-builders-and-generators 26 | allowlist: | 27 | slsa-framework/slsa-github-generator 28 | -------------------------------------------------------------------------------- /.github/workflows/pr-build.yaml: -------------------------------------------------------------------------------- 1 | name: pr-build 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | lint-chart: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Harden Runner 17 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 18 | with: 19 | egress-policy: audit 20 | 21 | - name: Checkout 22 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up Helm 27 | uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 #v3.5 28 | with: 29 | version: v3.4.0 30 | 31 | - uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 32 | with: 33 | python-version: 3.7 34 | 35 | - name: Set up chart-testing 36 | uses: helm/chart-testing-action@e6669bcd63d7cb57cb4380c33043eebe5d111992 # v2.6.1 37 | 38 | - name: Run chart-testing (lint) 39 | run: ct lint --target-branch=master --chart-dirs chart --check-version-increment=false 40 | 41 | lint: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Harden Runner 45 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 46 | with: 47 | egress-policy: audit 48 | 49 | - name: Checkout 50 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 51 | - name: Setup Go 52 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 53 | with: 54 | go-version: 1.20.5 55 | - name: fmt 56 | run: make fmt 57 | - name: vet 58 | run: make vet 59 | - name: lint 60 | run: make lint 61 | - name: Check if working tree is dirty 62 | run: | 63 | if [[ $(git diff --stat) != '' ]]; then 64 | git --no-pager diff 65 | echo 'run make test and commit changes' 66 | exit 1 67 | fi 68 | 69 | build: 70 | runs-on: ubuntu-latest 71 | outputs: 72 | profiles: ${{ steps.profiles.outputs.profiles }} 73 | steps: 74 | - name: Harden Runner 75 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 76 | with: 77 | egress-policy: audit 78 | 79 | - name: Checkout 80 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 81 | - name: Setup Go 82 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 83 | with: 84 | go-version: 1.20.5 85 | - name: Restore Go cache 86 | uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3.3.2 87 | with: 88 | path: ~/go/pkg/mod 89 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 90 | restore-keys: | 91 | ${{ runner.os }}-go- 92 | - name: Run tests 93 | run: make test 94 | - name: Build binary 95 | run: make build 96 | - name: Send go coverage report 97 | uses: shogo82148/actions-goveralls@7b1bd2871942af030d707d6574e5f684f9891fb2 # v1.8.0 98 | with: 99 | path-to-profile: coverage.out 100 | - name: Build container image 101 | run: | 102 | make docker-build 103 | - name: Create image tarball 104 | run: | 105 | docker save ghcr.io/raffis/mongodb-query-exporter:latest --output exporter-container.tar 106 | - name: Upload image 107 | uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 108 | with: 109 | name: exporter-container 110 | path: exporter-container.tar 111 | - id: profiles 112 | name: Determine test profiles 113 | run: | 114 | profiles=$(ls config/tests/cases | jq -R -s -c 'split("\n")[:-1]') 115 | echo $profiles 116 | echo "profiles=$profiles" >> $GITHUB_OUTPUT 117 | 118 | test-chart: 119 | runs-on: ubuntu-latest 120 | needs: 121 | - build 122 | - lint-chart 123 | steps: 124 | - name: Harden Runner 125 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 126 | with: 127 | egress-policy: audit 128 | 129 | - name: Checkout 130 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 131 | with: 132 | fetch-depth: 0 133 | 134 | - name: Set up Helm 135 | uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 #v3.5 136 | with: 137 | version: v3.4.0 138 | 139 | - uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236 # v4.7.1 140 | with: 141 | python-version: 3.7 142 | 143 | - name: Set up chart-testing 144 | uses: helm/chart-testing-action@e6669bcd63d7cb57cb4380c33043eebe5d111992 # v2.6.1 145 | 146 | - name: Create kind cluster 147 | uses: helm/kind-action@dda0770415bac9fc20092cacbc54aa298604d140 # v1.8.0 148 | 149 | - name: Download exporter container 150 | uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 151 | with: 152 | name: exporter-container 153 | path: /tmp 154 | - name: Load image 155 | run: | 156 | docker load --input /tmp/exporter-container.tar 157 | docker tag ghcr.io/raffis/mongodb-query-exporter:latest ghcr.io/raffis/mongodb-query-exporter:0.0.0 158 | docker image ls -a 159 | kind load docker-image ghcr.io/raffis/mongodb-query-exporter:0.0.0 --name chart-testing 160 | 161 | - name: Run chart-testing (install) 162 | run: ct install --target-branch=master --chart-dirs chart 163 | 164 | e2e-tests: 165 | runs-on: ubuntu-latest 166 | needs: 167 | - build 168 | strategy: 169 | matrix: 170 | profile: ${{ fromJson(needs.build.outputs.profiles) }} 171 | steps: 172 | - name: Harden Runner 173 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 174 | with: 175 | egress-policy: audit 176 | 177 | - name: Checkout 178 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 179 | - name: Download exporter container 180 | uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 181 | with: 182 | name: exporter-container 183 | path: /tmp 184 | - name: Load image 185 | run: | 186 | docker load --input /tmp/exporter-container.tar 187 | docker image ls -a 188 | - name: Setup Kubernetes 189 | uses: engineerd/setup-kind@aa272fe2a7309878ffc2a81c56cfe3ef108ae7d0 #v0.5.0 190 | with: 191 | version: v0.17.0 192 | - name: Setup Kustomize 193 | uses: imranismail/setup-kustomize@6691bdeb1b0a3286fb7f70fd1423c10e81e5375f # v2.0.0 194 | - name: Run test 195 | run: | 196 | make kind-test TEST_PROFILE=${{ matrix.profile }} 197 | - name: Debug failure 198 | if: failure() 199 | run: | 200 | kubectl -n mongo-system get pods 201 | kubectl -n mongo-system get pods -o yaml 202 | kubectl -n kube-system get pods 203 | kubectl -n mongo-system logs deploy/mongodb-query-exporter 204 | 205 | test-success: 206 | runs-on: ubuntu-latest 207 | needs: [e2e-tests] 208 | steps: 209 | - run: echo "all tests succeeded" 210 | -------------------------------------------------------------------------------- /.github/workflows/pr-dependency-review.yaml: -------------------------------------------------------------------------------- 1 | name: pr-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: Harden Runner 12 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 13 | with: 14 | egress-policy: audit 15 | 16 | - name: 'Checkout Repository' 17 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 18 | - name: 'Dependency Review' 19 | uses: actions/dependency-review-action@7bbfa034e752445ea40215fff1c3bf9597993d3f # v3.1.3 20 | -------------------------------------------------------------------------------- /.github/workflows/pr-label.yaml: -------------------------------------------------------------------------------- 1 | name: pr-label 2 | on: pull_request 3 | 4 | permissions: {} 5 | 6 | jobs: 7 | size-label: 8 | runs-on: ubuntu-latest 9 | if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' }} 10 | permissions: 11 | pull-requests: write 12 | steps: 13 | - name: Harden Runner 14 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 15 | with: 16 | egress-policy: audit 17 | 18 | - name: size-label 19 | uses: "pascalgn/size-label-action@b1f4946f381d38d3b5960f76b514afdfef39b609" 20 | env: 21 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 22 | -------------------------------------------------------------------------------- /.github/workflows/pr-nancy.yaml: -------------------------------------------------------------------------------- 1 | name: pr-nancy 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'master' 7 | 8 | permissions: {} 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | nancy: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Harden Runner 19 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 20 | with: 21 | egress-policy: audit 22 | 23 | - name: Checkout 24 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 25 | - name: Setup Go 26 | uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 27 | with: 28 | go-version: 1.20.5 29 | - name: WriteGoList 30 | run: go list -json -m all > go.list 31 | - name: Nancy SAST Scan 32 | uses: sonatype-nexus-community/nancy-github-action@726e338312e68ecdd4b4195765f174d3b3ce1533 # v1.0.3 33 | -------------------------------------------------------------------------------- /.github/workflows/pr-stale.yaml: -------------------------------------------------------------------------------- 1 | name: pr-stale 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | 6 | permissions: {} 7 | 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | steps: 14 | - uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0 15 | with: 16 | days-before-close: '120' 17 | stale-pr-label: stale 18 | repo-token: ${{ github.token }} 19 | -------------------------------------------------------------------------------- /.github/workflows/pr-trivy.yaml: -------------------------------------------------------------------------------- 1 | name: pr-trivy 2 | on: pull_request 3 | 4 | permissions: {} 5 | 6 | jobs: 7 | trivy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Harden Runner 11 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 12 | with: 13 | egress-policy: audit 14 | 15 | - name: Trivy fs scan 16 | uses: aquasecurity/trivy-action@2b6a709cf9c4025c5438138008beaddbb02086f0 # 0.14.0 17 | with: 18 | scan-type: 'fs' 19 | ignore-unfixed: true 20 | format: 'sarif' 21 | output: 'trivy-results.sarif' 22 | severity: 'CRITICAL,HIGH' 23 | -------------------------------------------------------------------------------- /.github/workflows/rebase.yaml: -------------------------------------------------------------------------------- 1 | name: rebase 2 | 3 | on: 4 | pull_request: 5 | types: [opened] 6 | issue_comment: 7 | types: [created] 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | rebase: 13 | if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase') && (github.event.comment.author_association == 'CONTRIBUTOR' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write # needed to force push 17 | steps: 18 | - name: Harden Runner 19 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 20 | with: 21 | egress-policy: audit 22 | 23 | - name: Checkout the latest code 24 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 25 | with: 26 | fetch-depth: 0 27 | - name: Automatic Rebase 28 | uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8 29 | env: 30 | GITHUB_TOKEN: ${{ github.token }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write # needed to write releases 14 | id-token: write # needed for keyless signing 15 | packages: write # needed for ghcr access 16 | steps: 17 | - name: Harden Runner 18 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 19 | with: 20 | egress-policy: audit 21 | 22 | - name: Checkout code 23 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 24 | with: 25 | fetch-depth: 0 26 | - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 27 | with: 28 | go-version: 1.20.5 29 | - name: Docker Login 30 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 31 | with: 32 | registry: ghcr.io 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | - name: Setup Cosign 36 | uses: sigstore/cosign-installer@1fc5bd396d372bee37d608f955b336615edf79c8 # v3.2.0 37 | - uses: anchore/sbom-action/download-syft@78fc58e266e87a38d4194b2137a3d4e9bcaf7ca1 # v0.14.3 38 | - name: Create release and SBOM 39 | if: startsWith(github.ref, 'refs/tags/v') 40 | uses: goreleaser/goreleaser-action@7ec5c2b0c6cdda6e8bbb49444bc797dd33d74dd8 # v5.0.0 41 | with: 42 | version: latest 43 | args: release --rm-dist --skip-validate 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | release-chart: 48 | runs-on: ubuntu-latest 49 | needs: 50 | - release 51 | permissions: 52 | packages: write # Needed to publish chart to ghcr.io 53 | id-token: write # Needed for keyless signing 54 | steps: 55 | - name: Harden Runner 56 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 57 | with: 58 | egress-policy: audit 59 | 60 | - name: Checkout 61 | uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4 62 | with: 63 | fetch-depth: 0 64 | 65 | - name: Install Helm 66 | uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 #v3.5 67 | 68 | - name: Setup Cosign 69 | uses: sigstore/cosign-installer@1fc5bd396d372bee37d608f955b336615edf79c8 # v3.2.0 70 | 71 | - name: Login to Github Container Registry using helm 72 | run: echo ${{ secrets.GITHUB_TOKEN }} | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin 73 | 74 | - name: Package helm charts 75 | run: | 76 | packVersion=$(echo "${{ github.ref_name }}" | sed 's/^v//g') 77 | helm package chart/mongodb-query-exporter -d chart --version=$packVersion --app-version=${{ github.ref_name }} 78 | 79 | - name: Publish helm charts to Github Container Registry 80 | run: | 81 | repository=$(echo "${{ github.repository_owner }}" | tr [:upper:] [:lower:]) 82 | helm push ${{ github.workspace }}/chart/mongodb-query-exporter-*.tgz oci://ghcr.io/$repository/charts |& tee .digest 83 | cosign login --username ${GITHUB_ACTOR} --password ${{ secrets.GITHUB_TOKEN }} ghcr.io 84 | cosign sign --yes ghcr.io/${{ github.repository_owner }}/charts/mongodb-query-exporter@$(cat .digest | awk -F "[, ]+" '/Digest/{print $NF}') 85 | -------------------------------------------------------------------------------- /.github/workflows/report-on-vulnerabilities.yaml: -------------------------------------------------------------------------------- 1 | name: report-on-vulnerabilities 2 | 3 | permissions: {} 4 | 5 | on: 6 | workflow_dispatch: {} 7 | schedule: 8 | - cron: '0 6 * * *' 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | scan: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | results: ${{ steps.parse-results.outputs.results }} 19 | steps: 20 | - name: Harden Runner 21 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 22 | with: 23 | egress-policy: audit 24 | 25 | - name: Scan for vulnerabilities 26 | uses: aquasecurity/trivy-action@2b6a709cf9c4025c5438138008beaddbb02086f0 # 0.14.0 (Trivy v0.34.0) 27 | with: 28 | image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 29 | format: json 30 | ignore-unfixed: false 31 | severity: HIGH,CRITICAL 32 | output: scan.json 33 | 34 | - name: Parse scan results 35 | id: parse-results 36 | continue-on-error: true 37 | run: | 38 | VULNS=$(cat scan.json | jq '.Results[] | length') 39 | if [[ $VULNS -eq 0 ]] 40 | then 41 | echo "No vulnerabilities found, halting" 42 | echo "results=nothing" >> $GITHUB_OUTPUT 43 | else 44 | echo "Vulnerabilities found, creating issue" 45 | echo "results=found" >> $GITHUB_OUTPUT 46 | fi 47 | 48 | - name: Upload vulnerability scan report 49 | uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 50 | if: steps.parse-results.outputs.results == 'found' 51 | with: 52 | name: scan.json 53 | path: scan.json 54 | if-no-files-found: error 55 | 56 | open-issue: 57 | runs-on: ubuntu-latest 58 | if: needs.scan.outputs.results == 'found' 59 | needs: scan 60 | permissions: 61 | issues: write 62 | steps: 63 | - name: Harden Runner 64 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 65 | with: 66 | egress-policy: audit 67 | 68 | - name: Checkout 69 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 70 | - name: Download scan 71 | uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 72 | with: 73 | name: scan.json 74 | - name: Set scan output 75 | id: set-scan-output 76 | run: echo "results=$(cat scan.json | jq -c)" >> $GITHUB_OUTPUT 77 | - uses: JasonEtco/create-an-issue@e27dddc79c92bc6e4562f268fffa5ed752639abd # v2.9.1 78 | env: 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | RESULTS: ${{ steps.set-scan-output.outputs.results }} 81 | with: 82 | filename: .github/ISSUE_TEMPLATE/VULN-TEMPLATE.md 83 | -------------------------------------------------------------------------------- /.github/workflows/scan.yaml: -------------------------------------------------------------------------------- 1 | name: scan 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '18 10 * * 3' 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | codeql: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read # for actions/checkout to fetch code 18 | security-events: write # for codeQL to write security events 19 | steps: 20 | - name: Harden Runner 21 | uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 22 | with: 23 | egress-policy: audit 24 | - name: Checkout repository 25 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 26 | - name: Initialize CodeQL 27 | uses: github/codeql-action/init@f0a12816612c7306b485a22cb164feb43c6df818 # codeql-bundle-20221020 28 | with: 29 | languages: go 30 | - name: Autobuild 31 | uses: github/codeql-action/autobuild@f0a12816612c7306b485a22cb164feb43c6df818 # codeql-bundle-20221020 32 | - name: Perform CodeQL Analysis 33 | uses: github/codeql-action/analyze@f0a12816612c7306b485a22cb164feb43c6df818 # codeql-bundle-20221020 34 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yaml: -------------------------------------------------------------------------------- 1 | name: scorecard 2 | on: 3 | branch_protection_rule: 4 | schedule: 5 | - cron: '18 14 * * 5' 6 | push: 7 | branches: [ "master" ] 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | scorecard: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | security-events: write 16 | id-token: write 17 | steps: 18 | - name: Harden Runner 19 | uses: step-security/harden-runner@eb238b55efaa70779f274895e782ed17c84f2895 # v2.6.1 20 | with: 21 | egress-policy: audit 22 | 23 | - name: "Checkout code" 24 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 25 | with: 26 | persist-credentials: false 27 | 28 | - name: "Run analysis" 29 | uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 30 | with: 31 | results_file: results.sarif 32 | results_format: sarif 33 | publish_results: true 34 | 35 | - name: "Upload artifact" 36 | uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 37 | with: 38 | name: SARIF file 39 | path: results.sarif 40 | retention-days: 5 41 | 42 | - name: "Upload to code-scanning" 43 | uses: github/codeql-action/upload-sarif@66b90a5db151a8042fa97405c6cf843bbe433f7b # v2.22.7 44 | with: 45 | sarif_file: results.sarif 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mongodb_query_exporter 2 | mongodb-query-exporter 3 | goreportcard.db 4 | ./config.yaml 5 | coverage.out 6 | **/charts 7 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | project_name: mongodb-query-exporter 2 | 3 | builds: 4 | - id: main 5 | main: cmd/main.go 6 | binary: mongodb-query-exporter 7 | goos: 8 | - linux 9 | - darwin 10 | - windows 11 | env: 12 | - CGO_ENABLED=0 13 | 14 | archives: 15 | - id: main 16 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 17 | builds: 18 | - main 19 | 20 | checksum: 21 | name_template: 'checksums.txt' 22 | 23 | source: 24 | enabled: true 25 | name_template: "{{ .ProjectName }}_{{ .Version }}_source_code" 26 | 27 | changelog: 28 | use: github-native 29 | 30 | sboms: 31 | - id: source 32 | artifacts: source 33 | documents: 34 | - "{{ .ProjectName }}_{{ .Version }}_sbom.spdx.json" 35 | 36 | dockers: 37 | - image_templates: 38 | - ghcr.io/raffis/{{ .ProjectName }}:v{{ .Version }}-amd64 39 | dockerfile: Dockerfile 40 | use: buildx 41 | ids: 42 | - main 43 | build_flag_templates: 44 | - --platform=linux/amd64 45 | - --label=org.opencontainers.image.title={{ .ProjectName }} 46 | - --label=org.opencontainers.image.description={{ .ProjectName }} 47 | - --label=org.opencontainers.image.url=https://github.com/raffis/{{ .ProjectName }} 48 | - --label=org.opencontainers.image.source=https://github.com/raffis/{{ .ProjectName }} 49 | - --label=org.opencontainers.image.version={{ .Version }} 50 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 51 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 52 | - --label=org.opencontainers.image.licenses=MIT 53 | - image_templates: 54 | - "ghcr.io/raffis/{{ .ProjectName }}:v{{ .Version }}-arm64v8" 55 | goarch: arm64 56 | dockerfile: Dockerfile 57 | use: buildx 58 | ids: 59 | - main 60 | build_flag_templates: 61 | - --platform=linux/arm64/v8 62 | - --label=org.opencontainers.image.title={{ .ProjectName }} 63 | - --label=org.opencontainers.image.description={{ .ProjectName }} 64 | - --label=org.opencontainers.image.url=https://github.com/raffis/{{ .ProjectName }} 65 | - --label=org.opencontainers.image.source=https://github.com/raffis/{{ .ProjectName }} 66 | - --label=org.opencontainers.image.version={{ .Version }} 67 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 68 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 69 | - --label=org.opencontainers.image.licenses=MIT 70 | 71 | docker_manifests: 72 | - name_template: ghcr.io/raffis/{{ .ProjectName }}:v{{ .Version }} 73 | image_templates: 74 | - ghcr.io/raffis/{{ .ProjectName }}:v{{ .Version }}-amd64 75 | - ghcr.io/raffis/{{ .ProjectName }}:v{{ .Version }}-arm64v8 76 | - name_template: ghcr.io/raffis/{{ .ProjectName }}:latest 77 | image_templates: 78 | - ghcr.io/raffis/{{ .ProjectName }}:v{{ .Version }}-amd64 79 | - ghcr.io/raffis/{{ .ProjectName }}:v{{ .Version }}-arm64v8 80 | 81 | signs: 82 | - cmd: cosign 83 | certificate: "${artifact}.pem" 84 | env: 85 | - COSIGN_EXPERIMENTAL=1 86 | args: 87 | - sign-blob 88 | - "--output-certificate=${certificate}" 89 | - "--output-signature=${signature}" 90 | - "${artifact}" 91 | - --yes 92 | artifacts: checksum 93 | output: true 94 | 95 | docker_signs: 96 | - cmd: cosign 97 | env: 98 | - COSIGN_EXPERIMENTAL=1 99 | artifacts: all 100 | output: true 101 | args: 102 | - 'sign' 103 | - '${artifact}' 104 | - --yes -------------------------------------------------------------------------------- /.nancy-ignore: -------------------------------------------------------------------------------- 1 | CVE-2022-29153 # no fix and this app does not make use of consul 2 | CVE-2020-8561 # no fix and this is only related to testcontainers 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/gitleaks/gitleaks 3 | rev: v8.16.3 4 | hooks: 5 | - id: gitleaks 6 | - repo: https://github.com/golangci/golangci-lint 7 | rev: v1.52.2 8 | hooks: 9 | - id: golangci-lint 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v4.4.0 12 | hooks: 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @raffis 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Maintainer Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.4, available at [http://contributor-covenant.org/version/1/4](http://contributor-covenant.org/version/1/4/). 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Release process 2 | 3 | ### Controller release 4 | 1. Merge all pr's to master which need to be part of the new release 5 | 2. Create pr to master with these changes: 6 | 1. Bump kustomization 7 | 2. Create CHANGELOG.md entry with release and date 8 | 3. Merge pr 9 | 4. Push a tag following semantic versioning prefixed by 'v'. Do not create a github release, this is done automatically. 10 | 5. Create new branch and add the following changes: 11 | 1. Bump chart version 12 | 2. Bump charts app version 13 | 6. Create pr to master and merge 14 | 15 | ### Helm chart change only 16 | 1. Create branch with changes 17 | 2. Bump chart version 18 | 3. Create pr to master and merge 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static:nonroot@sha256:92d40eea0b5307a94f2ebee3e94095e704015fb41e35fc1fcbd1d151cc282222 2 | WORKDIR / 3 | COPY mongodb-query-exporter mongodb-query-exporter 4 | EXPOSE 9412 5 | 6 | ENTRYPOINT ["/mongodb-query-exporter"] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 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 | GO := go 2 | GOPATH := $(firstword $(subst :, ,$(shell $(GO) env GOPATH))) 3 | KUSTOMIZE := kustomize 4 | IMG := ghcr.io/raffis/mongodb-query-exporter:latest 5 | pkgs := $(shell $(GO) list ./... | grep -v /vendor/) 6 | units := $(shell $(GO) list ./... | grep -v /vendor/ | grep -v cmd) 7 | integrations := $(shell $(GO) list ./... | grep cmd) 8 | 9 | PREFIX ?= $(shell pwd) 10 | BIN_DIR ?= $(shell pwd) 11 | 12 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 13 | ifeq (,$(shell go env GOBIN)) 14 | GOBIN=$(shell go env GOPATH)/bin 15 | else 16 | GOBIN=$(shell go env GOBIN) 17 | endif 18 | 19 | all: deps vet fmt lint test build 20 | 21 | style: 22 | @echo ">> checking code style" 23 | @! gofmt -d $(shell find . -path ./vendor -prune -o -name '*.go' -print) | grep '^' 24 | 25 | test: unittest integrationtest 26 | 27 | unittest: 28 | @echo ">> running unit tests" 29 | @$(GO) test -short -race -v -coverprofile=coverage.out $(units) 30 | 31 | integrationtest: 32 | @echo ">> running integration tests" 33 | @$(GO) test -short -race -v $(integrations) 34 | 35 | GOLANGCI_LINT = $(GOBIN)/golangci-lint 36 | .PHONY: golangci-lint 37 | golangci-lint: ## Download golint locally if necessary 38 | $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2) 39 | 40 | .PHONY: lint 41 | lint: golangci-lint ## Run golangci-lint against code 42 | $(GOLANGCI_LINT) run ./... 43 | 44 | deps: 45 | @echo ">> install dependencies" 46 | @$(GO) mod download 47 | 48 | .PHONY: fmt 49 | fmt: ## Run go fmt against code. 50 | go fmt ./... 51 | gofmt -s -w . 52 | 53 | .PHONY: tidy 54 | tidy: ## Run go mod tidy 55 | go mod tidy 56 | 57 | .PHONY: vet 58 | vet: ## Run go vet against code. 59 | go vet ./... 60 | 61 | build: 62 | @echo ">> building binaries" 63 | CGO_ENABLED=0 go build -o mongodb-query-exporter cmd/main.go 64 | 65 | .PHONY: run 66 | run: 67 | go run ./cmd/main.go 68 | 69 | .PHONY: docker-build 70 | docker-build: build ## Build docker image with the manager. 71 | docker build -t ${IMG} . 72 | 73 | .PHONY: docker-push 74 | docker-push: ## Push docker image with the manager. 75 | docker push ${IMG} 76 | 77 | TEST_PROFILE=mongodb-v5 78 | CLUSTER=kind 79 | 80 | .PHONY: kind-test 81 | kind-test: ## Deploy including test 82 | kind load docker-image ${IMG} --name ${CLUSTER} 83 | kubectl --context kind-${CLUSTER} -n mongo-system delete pods --all 84 | kustomize build config/tests/cases/${TEST_PROFILE} --enable-helm | kubectl --context kind-${CLUSTER} apply -f - 85 | kubectl --context kind-${CLUSTER} -n mongo-system wait --for=condition=Ready pods -l app.kubernetes.io/managed-by!=Helm -l verify=yes --timeout=3m 86 | kubectl --context kind-${CLUSTER} -n mongo-system wait --for=jsonpath='{.status.conditions[1].reason}'=PodCompleted pods -l app.kubernetes.io/managed-by!=Helm -l verify=yes --timeout=3m 87 | kustomize build config/tests/cases/${TEST_PROFILE} --enable-helm | kubectl --context kind-${CLUSTER} delete -f - 88 | 89 | .PHONY: deploy 90 | deploy: 91 | cd deploy/exporter && $(KUSTOMIZE) edit set image ghcr.io/raffis/mongodb-query-exporter=${IMG} 92 | $(KUSTOMIZE) build config/base | kubectl apply -f - 93 | 94 | .PHONY: undeploy 95 | undeploy: ## Undeploy exporter from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 96 | $(KUSTOMIZE) build config/default | kubectl delete --ignore-not-found=$(ignore-not-found) -f - 97 | 98 | .PHONY: all style fmt build test vet 99 | 100 | # go-install-tool will 'go install' any package $2 and install it to $1 101 | define go-install-tool 102 | @[ -f $(1) ] || { \ 103 | set -e ;\ 104 | TMP_DIR=$$(mktemp -d) ;\ 105 | cd $$TMP_DIR ;\ 106 | go mod init tmp ;\ 107 | echo "Downloading $(2)" ;\ 108 | env -i bash -c "GOBIN=$(GOBIN) PATH=$(PATH) GOPATH=$(shell go env GOPATH) GOCACHE=$(shell go env GOCACHE) go install $(2)" ;\ 109 | rm -rf $$TMP_DIR ;\ 110 | } 111 | endef 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prometheus MongoDB query exporter 2 | [![release](https://github.com/raffis/mongodb-query-exporter/actions/workflows/release.yaml/badge.svg)](https://github.com/raffis/mongodb-query-exporter/actions/workflows/release.yaml) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/raffis/mongodb-query-exporter/v5)](https://goreportcard.com/report/github.com/raffis/mongodb-query-exporter/v5) 4 | [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/raffis/mongodb-query-exporter/badge)](https://api.securityscorecards.dev/projects/github.com/raffis/mongodb-query-exporter) 5 | [![Coverage Status](https://coveralls.io/repos/github/raffis/mongodb-query-exporter/badge.svg?branch=master)](https://coveralls.io/github/raffis/mongodb-query-exporter?branch=master) 6 | [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/mongodb-query-exporter)](https://artifacthub.io/packages/search?repo=mongodb-query-exporter) 7 | 8 | MongoDB aggregation query exporter for [Prometheus](https://prometheus.io). 9 | 10 | ## Features 11 | 12 | * Support for gauge metrics 13 | * Pull and Push (Push is only supported for MongoDB >= 3.6) 14 | * Supports multiple MongoDB servers 15 | * Metric caching support 16 | 17 | Note that this is not designed to be a replacement for the [MongoDB exporter](https://github.com/percona/mongodb_exporter) to instrument MongoDB internals. This application exports custom MongoDB metrics in the prometheus format based on the queries (aggregations) you want. 18 | 19 | ## Installation 20 | 21 | Get Prometheus MongoDB aggregation query exporter, either as a binaray from the latest release or packaged as a [Docker image](https://github.com/raffis/mongodb-query-exporter/pkgs/container/mongodb-query-exporter). 22 | 23 | ### Helm Chart 24 | For kubernetes users there is an official helm chart for the MongoDB query exporter. 25 | Please read the installation instructions [here](https://github.com/raffis/mongodb-query-exporter/blob/master/chart/mongodb-query-exporter/README.md). 26 | 27 | ### Docker 28 | You can run the exporter using docker (This will start it using the example config provided in the example folder): 29 | ```sh 30 | docker run -e MDBEXPORTER_CONFIG=/config/configv3.yaml -v $(pwd)/example:/config ghcr.io/raffis/mongodb-query-exporter:latest 31 | ``` 32 | 33 | ## Usage 34 | 35 | ``` 36 | $ mongodb-query-exporter 37 | ``` 38 | 39 | Use the `-help` flag to get help information. 40 | 41 | If you use [MongoDB Authorization](https://docs.mongodb.org/manual/core/authorization/), best practices is to create a dedicated readonly user with access to all databases/collections required: 42 | 43 | 1. Create a user with '*read*' on your database, like the following (*replace username/password/db!*): 44 | 45 | ```js 46 | db.getSiblingDB("admin").createUser({ 47 | user: "mongodb_query_exporter", 48 | pwd: "secret", 49 | roles: [ 50 | { role: "read", db: "mydb" } 51 | ] 52 | }) 53 | ``` 54 | 55 | 2. Set environment variable `MONGODB_URI` before starting the exporter: 56 | 57 | ```bash 58 | export MDBEXPORTER_MONGODB_URI=mongodb://mongodb_query_exporter:secret@localhost:27017 59 | ``` 60 | 61 | Note: The URI is substituted using env variables `${MY_ENV}`, given that you may also pass credentials from other env variables. See the example bellow. 62 | 63 | If you use [x.509 Certificates to Authenticate Clients](https://docs.mongodb.com/manual/tutorial/configure-x509-client-authentication/), pass in username and `authMechanism` via [connection options](https://docs.mongodb.com/manual/reference/connection-string/#connections-connection-options) to the MongoDB uri. Eg: 64 | 65 | ``` 66 | mongodb://CN=myName,OU=myOrgUnit,O=myOrg,L=myLocality,ST=myState,C=myCountry@localhost:27017/?authMechanism=MONGODB-X509 67 | ``` 68 | 69 | ## Credentials from env variables 70 | You can pass in credentials from env variables. 71 | 72 | Given the following URI the exporter will look for the ENV variables called `MY_USERNAME` and `MY_PASSWORD` and automatically use them at the referenced position within the URI. 73 | ```bash 74 | export MY_USERNAME=mongodb_query_exporter 75 | export MY_PASSWORD=secret 76 | export MDBEXPORTER_MONGODB_URI=mongodb://${MY_USERNAME}:${MY_PASSWORD}@localhost:27017 77 | ``` 78 | 79 | ## Access metrics 80 | The metrics are by default exposed at `/metrics`. 81 | 82 | ``` 83 | curl localhost:9412/metrics 84 | ``` 85 | 86 | ## Exporter configuration 87 | 88 | The exporter is looking for a configuration in `~/.mongodb_query_exporter/config.yaml` and `/etc/mongodb_query_exporter/config.yaml` or if set the path from the env `MDBEXPORTER_CONFIG`. 89 | 90 | You may also use env variables to configure the exporter: 91 | 92 | | Env variable | Description | Default | 93 | |--------------------------|------------------------------------------|---------| 94 | | MDBEXPORTER_CONFIG | Custom path for the configuration | `~/.mongodb_query_exporter/config.yaml` or `/etc/mongodb_query_exporter/config.yaml` | 95 | | MDBEXPORTER_MONGODB_URI | The MongoDB connection URI | `mongodb://localhost:27017` 96 | | MDBEXPORTER_MONGODB_QUERY_TIMEOUT | Timeout until a MongoDB operations gets aborted | `10` | 97 | | MDBEXPORTER_LOG_LEVEL | Log level | `warning` | 98 | | MDBEXPORTER_LOG_ENCODING | Log format | `json` | 99 | | MDBEXPORTER_BIND | Bind address for the HTTP server | `:9412` | 100 | | MDBEXPORTER_METRICSPATH | Metrics endpoint | `/metrics` | 101 | 102 | Note if you have multiple MongoDB servers you can inject an env variable for each instead using `MDBEXPORTER_MONGODB_URI`: 103 | 104 | 1. `MDBEXPORTER_SERVER_0_MONGODB_URI=mongodb://srv1:27017` 105 | 2. `MDBEXPORTER_SERVER_1_MONGODB_URI=mongodb://srv2:27017` 106 | 3. ... 107 | 108 | ## Configure metrics 109 | 110 | Since the v1.0.0 release you should use the config version v3.0 to profit from the latest features. 111 | See the configuration version matrix bellow. 112 | 113 | Example: 114 | ```yaml 115 | version: 3.0 116 | bind: 0.0.0.0:9412 117 | log: 118 | encoding: json 119 | level: info 120 | development: false 121 | disableCaller: false 122 | global: 123 | queryTimeout: 3s 124 | maxConnection: 3 125 | defaultCache: 0 126 | servers: 127 | - name: main 128 | uri: mongodb://localhost:27017 129 | aggregations: 130 | - database: mydb 131 | collection: objects 132 | servers: [main] #Can also be empty, if empty the metric will be used for every server defined 133 | metrics: 134 | - name: myapp_example_simplevalue_total 135 | type: gauge #Can also be empty, the default is gauge 136 | help: 'Simple gauge metric' 137 | value: total 138 | overrideEmpty: true # if an empty result set is returned.. 139 | emptyValue: 0 # create a metric with value 0 140 | labels: [] 141 | constLabels: 142 | region: eu-central-1 143 | cache: 0 144 | mode: pull 145 | pipeline: | 146 | [ 147 | {"$count":"total"} 148 | ] 149 | - database: mydb 150 | collection: queue 151 | metrics: 152 | - name: myapp_example_processes_total 153 | type: gauge 154 | help: 'The total number of processes in a job queue' 155 | value: total 156 | labels: [type,status] 157 | constLabels: {} 158 | mode: pull 159 | pipeline: | 160 | [ 161 | {"$group": { 162 | "_id":{"status":"$status","name":"$class"}, 163 | "total":{"$sum":1} 164 | }}, 165 | {"$project":{ 166 | "_id":0, 167 | "type":"$_id.name", 168 | "total":"$total", 169 | "status": { 170 | "$switch": { 171 | "branches": [ 172 | { "case": { "$eq": ["$_id.status", 0] }, "then": "waiting" }, 173 | { "case": { "$eq": ["$_id.status", 1] }, "then": "postponed" }, 174 | { "case": { "$eq": ["$_id.status", 2] }, "then": "processing" }, 175 | { "case": { "$eq": ["$_id.status", 3] }, "then": "done" }, 176 | { "case": { "$eq": ["$_id.status", 4] }, "then": "failed" }, 177 | { "case": { "$eq": ["$_id.status", 5] }, "then": "canceled" }, 178 | { "case": { "$eq": ["$_id.status", 6] }, "then": "timeout" } 179 | ], 180 | "default": "unknown" 181 | }} 182 | }} 183 | ] 184 | - database: mydb 185 | collection: events 186 | metrics: 187 | - name: myapp_events_total 188 | type: gauge 189 | help: 'The total number of events (created 1h ago or newer)' 190 | value: count 191 | labels: [type] 192 | constLabels: {} 193 | mode: pull 194 | # Note $$NOW is only supported in MongoDB >= 4.2 195 | pipeline: | 196 | [ 197 | { "$sort": { "created": -1 }}, 198 | {"$limit": 100000}, 199 | {"$match":{ 200 | "$expr": { 201 | "$gte": [ 202 | "$created", 203 | { 204 | "$subtract": ["$$NOW", 3600000] 205 | } 206 | ] 207 | } 208 | }}, 209 | {"$group": { 210 | "_id":{"type":"$type"}, 211 | "count":{"$sum":1} 212 | }}, 213 | {"$project":{ 214 | "_id":0, 215 | "type":"$_id.type", 216 | "count":"$count" 217 | }} 218 | ] 219 | ``` 220 | 221 | See more examples in the `/example` folder. 222 | 223 | ### Info metrics 224 | 225 | By defining no actual value field but set `overrideEmpty` to `true` a metric can sill be exported 226 | with labels from the aggregation pipeline but the value is set to a static value taken from `emptyValue`. 227 | This is useful for exporting info metrics which can later be used for join queries. 228 | 229 | ```yaml 230 | servers: 231 | - name: main 232 | uri: mongodb://localhost:27017 233 | aggregations: 234 | - database: mydb 235 | collection: objects 236 | metrics: 237 | - name: myapp_info 238 | help: 'Info metric' 239 | overrideEmpty: true 240 | emptyValue: 1 241 | labels: 242 | - mylabel1 243 | - mylabel2 244 | constLabels: 245 | region: eu-central-1 246 | cache: 0 247 | mode: pull 248 | pipeline: `...` 249 | ``` 250 | 251 | 252 | ## Supported config versions 253 | 254 | | Config version | Supported since | 255 | |--------------------------|-------------------| 256 | | `v3.0` | v1.0.0 | 257 | | `v2.0` | v1.0.0-beta5 | 258 | | `v1.0` | v1.0.0-beta1 | 259 | 260 | 261 | ## Cache & Push 262 | Prometheus is designed to scrape metrics. During each scrape the mongodb-query-exporter will evaluate all configured metrics. 263 | If you have expensive queries there is an option to cache the aggregation result by setting a cache ttl. 264 | However it is more effective to **avoid cache** and design good aggregation pipelines. In some cases a different scrape interval might also be a solution. 265 | For individual aggregations and/or MongoDB servers older than 3.6 it might still be a good option though. 266 | 267 | A better approach is using push instead a static cache, see bellow. 268 | 269 | Example: 270 | ```yaml 271 | aggregations: 272 | - metrics: 273 | - name: myapp_example_simplevalue_total 274 | help: 'Simple gauge metric which is cached for 5min' 275 | value: total 276 | servers: [main] 277 | mode: pull 278 | cache: 5m 279 | database: mydb 280 | collection: objects 281 | pipeline: | 282 | [ 283 | {"$count":"total"} 284 | ] 285 | ``` 286 | 287 | To reduce load on the MongoDB server (and also scrape time) there is a push mode. Push automatically caches the metric at scrape time preferred (If no cache ttl is set). However the cache for a metric with mode push 288 | will be invalidated automatically if anything changes within the configured MongoDB collection. Meaning the aggregation will only be executed if there have been changes during scrape intervals. 289 | 290 | >**Note**: This requires at least MongoDB 3.6. 291 | 292 | Example: 293 | ```yaml 294 | aggregations: 295 | - metrics: 296 | - name: myapp_example_simplevalue_total 297 | help: 'Simple gauge metric' 298 | value: total 299 | servers: [main] 300 | # With the mode push the pipeline is only executed if a change occured on the collection called objects 301 | mode: push 302 | database: mydb 303 | collection: objects 304 | pipeline: | 305 | [ 306 | {"$count":"total"} 307 | ] 308 | ``` 309 | 310 | ## Debug 311 | The mongodb-query-exporters also publishes a counter metric called `mongodb_query_exporter_query_total` which counts query results for each configured aggregation. 312 | Furthermore you might increase the log level to get more insight. 313 | 314 | ## Used by 315 | * The balloon helm chart implements the mongodb-query-exporter to expose general stats from the MongoDB like the number of total nodes or files stored internally or externally. 316 | See the [config-map here](https://github.com/gyselroth/balloon-helm/blob/master/unstable/balloon/charts/balloon-mongodb-metrics/templates/config-map.yaml). 317 | 318 | 319 | Please submit a PR if your project should be listed here! 320 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | ### When you should? 5 | - You think you discovered a potential security vulnerability. 6 | - You are unsure how a vulnerability affects this application. 7 | - You think you discovered a vulnerability in another project that this application depends on. For projects with their own vulnerability reporting and disclosure process, please report it directly there. 8 | 9 | ### When you should not? 10 | - You need help tuning application components for security 11 | - You need help applying security-related updates. 12 | - Your issue is not security-related. 13 | 14 | ### Please use the below process to report a vulnerability to the project: 15 | 1. Email raffael.sahli+security@gmail.com 16 | * Emails should contain: 17 | * description of the problem 18 | * precise and detailed steps (include screenshots) that created the problem 19 | * the affected version(s) 20 | * any possible mitigations, if known 21 | 2. You may be contacted by a project maintainer to further discuss the reported item. Please bear with us as we seek to understand the breadth and scope of the reported problem, recreate it, and confirm if there is a vulnerability present. 22 | 23 | ## Supported Versions 24 | Versions follow [Semantic Versioning](https://semver.org/) terminology and are expressed as x.y.z: 25 | - where x is the major version 26 | - y is the minor version 27 | - and z is the patch version 28 | 29 | Security fixes, may be backported to the three most recent minor releases, depending on severity and feasibility. Patch releases are cut from those branches periodically, plus additional urgent releases, when required. -------------------------------------------------------------------------------- /artifacthub-repo.yml: -------------------------------------------------------------------------------- 1 | repositoryID: 4e4a5e32-0c56-4f3a-9771-4176af0329de 2 | owners: 3 | - name: Raffael Sahli 4 | email: raffael.sahli@gmail.com 5 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | appVersion: 0.0.0 3 | description: A Prometheus exporter for MongoDB custom aggregations 4 | home: https://github.com/raffis/mongodb-query-exporter 5 | maintainers: 6 | - name: raffis 7 | email: raffael.sahli@gmail.com 8 | keywords: 9 | - exporter 10 | - metrics 11 | - mongodb 12 | - aggregation 13 | - query 14 | - prometheus 15 | name: mongodb-query-exporter 16 | sources: 17 | - https://github.com/raffis/mongodb-query-exporter 18 | version: 0.0.0 19 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/README.md: -------------------------------------------------------------------------------- 1 | # MongoDB Exporter 2 | 3 | Installs the [MongoDB Query Exporter](https://github.com/raffis/mongodb-query-exporter) for [Prometheus](https://prometheus.io/). 4 | 5 | ## Installing the Chart 6 | 7 | To install the chart with the release name `mongodb-query-exporter`: 8 | 9 | ```console 10 | helm upgrade mongodb-query-exporter --install oci://ghcr.io/raffis/charts/mongodb-query-exporter --set mongodb[0]=mongodb://mymongodb:27017 --set-file config=../../example/configv2.yaml 11 | ``` 12 | 13 | This command deploys the MongoDB Exporter with the default configuration. The [configuration](#configuration) section lists the parameters that can be configured during installation. 14 | 15 | ## Using the Chart 16 | 17 | To use the chart, please add your MongoDB server to the list of servers you want to query `mongodb` and ensure it is populated with a valid [MongoDB URI](https://docs.mongodb.com/manual/reference/connection-string). 18 | You may add multiple ones if you want to query more than one MongoDB server. 19 | Or an existing secret (in the releases namespace) with MongoDB URI's referred via `existingSecret.name`. 20 | If the MongoDB server requires authentication, credentials should be populated in the connection string as well. The MongoDB query exporter supports 21 | connecting to either a MongoDB replica set member, shard, or standalone instance. 22 | 23 | The chart comes with a ServiceMonitor for use with the [Prometheus Operator](https://github.com/helm/charts/tree/master/stable/prometheus-operator). 24 | If you're not using the Prometheus Operator, you can disable the ServiceMonitor by setting `serviceMonitor.enabled` to `false` and instead 25 | populate the `podAnnotations` as below: 26 | 27 | ```yaml 28 | podAnnotations: 29 | prometheus.io/scrape: "true" 30 | prometheus.io/port: "metrics" 31 | prometheus.io/path: "/metrics" 32 | ``` 33 | 34 | ## Configuration 35 | 36 | See Customizing the Chart Before Installing. To see all configurable options with detailed comments, visit the chart's values.yaml, or run the configuration command: 37 | 38 | ```sh 39 | $ helm show values oci://ghcr.io/raffis/charts/mongodb-query-exporter 40 | ``` 41 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | mongodb-query-export was successfully deployed. 2 | 3 | You may test the exporter using: 4 | kubectl -n {{.Release.Namespace}} port-forward deployment/{{.Release.Name}} {{.Values.port}} & 5 | curl localhost:{{.Values.port}}/metrics 6 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "mongodb-query-exporter.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "mongodb-query-exporter.fullname" -}} 15 | {{- if .Values.fullnameOverride -}} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 17 | {{- else -}} 18 | {{- $name := default .Chart.Name .Values.nameOverride -}} 19 | {{- if contains $name .Release.Name -}} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 21 | {{- else -}} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 23 | {{- end -}} 24 | {{- end -}} 25 | {{- end -}} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "mongodb-query-exporter.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 32 | {{- end -}} 33 | 34 | {{/* 35 | Create the name of the service account to use 36 | */}} 37 | {{- define "mongodb-query-exporter.serviceAccountName" -}} 38 | {{- if .Values.serviceAccount.create -}} 39 | {{ default (include "mongodb-query-exporter.fullname" .) .Values.serviceAccount.name }} 40 | {{- else -}} 41 | {{ default "default" .Values.serviceAccount.name }} 42 | {{- end -}} 43 | {{- end -}} 44 | 45 | {{/* 46 | Determine secret name, can either be the self-created of an existing one 47 | */}} 48 | {{- define "mongodb-query-exporter.secretName" -}} 49 | {{- if .Values.existingSecret.name -}} 50 | {{- .Values.existingSecret.name -}} 51 | {{- else -}} 52 | {{ include "mongodb-query-exporter.fullname" . }} 53 | {{- end -}} 54 | {{- end -}} 55 | 56 | {{/* 57 | Determine configmap name, can either be the self-created of an existing one 58 | */}} 59 | {{- define "mongodb-query-exporter.configName" -}} 60 | {{- if .Values.existingConfig.name -}} 61 | {{- .Values.existingConfig.name -}} 62 | {{- else -}} 63 | {{ include "mongodb-query-exporter.fullname" . }} 64 | {{- end -}} 65 | {{- end -}} 66 | 67 | 68 | {{/* 69 | Common labels 70 | */}} 71 | {{- define "mongodb-query-exporter.labels" -}} 72 | {{ if .Values.chartLabels -}} 73 | app.kubernetes.io/name: {{ include "mongodb-query-exporter.name" . }} 74 | app.kubernetes.io/instance: {{ .Release.Name }} 75 | app.kubernetes.io/managed-by: {{ .Release.Service }} 76 | helm.sh/chart: {{ include "mongodb-query-exporter.chart" . }} 77 | {{- end -}} 78 | {{ if .Values.labels }} 79 | {{ toYaml .Values.labels }} 80 | {{- end -}} 81 | {{- end -}} 82 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.existingConfig.name -}} 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ include "mongodb-query-exporter.configName" . }} 6 | labels: {{- include "mongodb-query-exporter.labels" . | nindent 4 }} 7 | {{- with .Values.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | data: 12 | config.yaml: | 13 | {{ .Values.config | nindent 4}} 14 | {{- end -}} 15 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "mongodb-query-exporter.fullname" . }} 5 | labels: {{- include "mongodb-query-exporter.labels" . | nindent 4 }} 6 | {{- with .Values.annotations }} 7 | annotations: 8 | {{- toYaml . | nindent 4 }} 9 | {{- end }} 10 | spec: 11 | replicas: {{ .Values.replicas }} 12 | selector: 13 | matchLabels: 14 | app.kubernetes.io/name: {{ include "mongodb-query-exporter.name" . }} 15 | app.kubernetes.io/instance: {{ .Release.Name }} 16 | template: 17 | metadata: 18 | annotations: 19 | checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} 20 | checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} 21 | {{- if .Values.podAnnotations }} 22 | {{- toYaml .Values.podAnnotations | nindent 8 }} 23 | {{- end }} 24 | labels: 25 | app.kubernetes.io/name: {{ include "mongodb-query-exporter.name" . }} 26 | app.kubernetes.io/instance: {{ .Release.Name }} 27 | spec: 28 | serviceAccountName: {{ template "mongodb-query-exporter.serviceAccountName" . }} 29 | containers: 30 | - name: mongodb-query-exporter 31 | envFrom: 32 | - secretRef: 33 | name: {{ include "mongodb-query-exporter.secretName" . }} 34 | env: 35 | {{- if .Values.env }} 36 | {{- range $key, $value := .Values.env }} 37 | - name: "{{ $key }}" 38 | value: "{{ $value }}" 39 | {{- end }} 40 | {{- end }} 41 | {{- range $key, $value := .Values.extraEnvSecrets }} 42 | - name: {{ $key }} 43 | valueFrom: 44 | secretKeyRef: 45 | name: {{ required "Must specify secret!" $value.secret }} 46 | key: {{ required "Must specify key!" $value.key }} 47 | {{- end }} 48 | {{- if .Values.envFromSecret }} 49 | envFrom: 50 | - secretRef: 51 | name: {{ .Values.envFromSecret }} 52 | {{- end }} 53 | {{- range $key, $value := .Values.extraEnvFieldPath }} 54 | - name: {{ $key }} 55 | valueFrom: 56 | fieldRef: 57 | fieldPath: {{ $value }} 58 | {{- end }} 59 | image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}" 60 | imagePullPolicy: {{ .Values.image.pullPolicy }} 61 | args: 62 | - --bind={{ printf ":%s" .Values.port }} 63 | - --path={{ .Values.metricsPath }} 64 | {{- if .Values.extraArgs }} 65 | {{- toYaml .Values.extraArgs | nindent 8 }} 66 | {{- end }} 67 | ports: 68 | - name: metrics 69 | containerPort: {{ .Values.port }} 70 | protocol: TCP 71 | livenessProbe: 72 | {{- toYaml .Values.livenessProbe | nindent 10 }} 73 | readinessProbe: 74 | {{- toYaml .Values.readinessProbe | nindent 10 }} 75 | resources: 76 | {{- toYaml .Values.resources | nindent 10 }} 77 | securityContext: 78 | {{- toYaml .Values.securityContext | nindent 10 }} 79 | volumeMounts: 80 | - name: config 81 | mountPath: /etc/mongodb_query_exporter 82 | {{- range .Values.secretMounts }} 83 | - name: {{ .name }} 84 | mountPath: {{ .path }} 85 | {{- if .subPath }} 86 | subPath: {{ .subPath }} 87 | {{- end }} 88 | {{- end }} 89 | {{- if .Values.extraContainers }} 90 | {{- toYaml .Values.extraContainers | nindent 6 }} 91 | {{- end }} 92 | volumes: 93 | - name: config 94 | configMap: 95 | name: {{ include "mongodb-query-exporter.configName" . }} 96 | {{- range .Values.secretMounts }} 97 | - name: {{ .name }} 98 | secret: 99 | secretName: {{ .secretName }} 100 | {{- end }} 101 | {{- with .Values.affinity }} 102 | affinity: 103 | {{- toYaml . | nindent 8 }} 104 | {{- end }} 105 | {{- with .Values.imagePullSecrets }} 106 | imagePullSecrets: 107 | {{- toYaml . | nindent 8 }} 108 | {{- end }} 109 | {{- with .Values.nodeSelector }} 110 | nodeSelector: 111 | {{- toYaml . | nindent 8 }} 112 | {{- end }} 113 | {{- if .Values.priorityClassName }} 114 | priorityClassName: {{ .Values.priorityClassName }} 115 | {{- end }} 116 | terminationGracePeriodSeconds: 30 117 | {{- with .Values.tolerations }} 118 | tolerations: 119 | {{- toYaml . | nindent 8 }} 120 | {{- end }} 121 | {{- with .Values.topologySpreadConstraints }} 122 | topologySpreadConstraints: 123 | {{- toYaml .Values.topologySpreadConstraints | nindent 8 }} 124 | {{- end }} 125 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/podmonitor.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.podMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: PodMonitor 4 | metadata: 5 | name: {{ include "mongodb-query-exporter.fullname" . }} 6 | labels: {{- merge ( include "mongodb-query-exporter.labels" . | fromYaml) .Values.podMonitor.labels | toYaml | nindent 4 }} 7 | {{- with .Values.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | {{- if .Values.podMonitor.namespace }} 12 | namespace: {{ .Values.podMonitor.namespace }} 13 | {{- end }} 14 | spec: 15 | podMetricsEndpoints: 16 | - port: metrics 17 | path: {{ .Values.metricsPath }} 18 | interval: {{ .Values.podMonitor.interval }} 19 | scrapeTimeout: {{ .Values.podMonitor.scrapeTimeout }} 20 | {{- if .Values.podMonitor.metricRelabelings }} 21 | metricRelabelings: {{ toYaml .Values.podMonitor.metricRelabelings | nindent 4 }} 22 | {{- end }} 23 | 24 | namespaceSelector: 25 | matchNames: 26 | - {{ .Release.Namespace }} 27 | selector: 28 | matchLabels: 29 | app.kubernetes.io/name: {{ include "mongodb-query-exporter.name" . }} 30 | app.kubernetes.io/instance: {{ .Release.Name }} 31 | {{- if .Values.podMonitor.targetLabels }} 32 | targetLabels: 33 | {{- range .Values.podMonitor.targetLabels }} 34 | - {{ . }} 35 | {{- end }} 36 | {{- end }} 37 | sampleLimit: {{ .Values.podMonitor.sampleLimit }} 38 | {{- end }} 39 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/prometheusrule.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.prometheusRule.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: PrometheusRule 4 | metadata: 5 | metadata: 6 | name: {{ template "mongodb-query-exporter.fullname" . }} 7 | labels: {{- merge ( include "mongodb-query-exporter.labels" . | fromYaml) .Values.prometheusRule.labels | toYaml | nindent 4 }} 8 | {{- with .Values.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- if .Values.prometheusRule.namespace }} 13 | namespace: {{ .Values.prometheusRule.namespace }} 14 | {{- end }} 15 | spec: 16 | {{- with .Values.prometheusRule.rules }} 17 | groups: 18 | - name: {{ template "mongodb-query-exporter.name" $ }} 19 | rules: {{ tpl (toYaml .) $ | nindent 8 }} 20 | {{- end }} 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.existingSecret.name -}} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ include "mongodb-query-exporter.secretName" . }} 6 | labels: {{- include "mongodb-query-exporter.labels" . | nindent 4 }} 7 | {{- with .Values.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | type: Opaque 12 | data: 13 | {{- if .Values.mongodb }} 14 | {{- range $key, $value := .Values.mongodb }} 15 | MDBEXPORTER_SERVER_{{ $key }}_MONGODB_URI: "{{ $value | b64enc }}" 16 | {{- end }} 17 | {{- end }} 18 | {{- end -}} 19 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.service.enabled }} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ include "mongodb-query-exporter.fullname" . }} 6 | labels: {{- merge ( include "mongodb-query-exporter.labels" . | fromYaml) .Values.service.labels | toYaml | nindent 4 }} 7 | {{- with .Values.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | spec: 12 | ports: 13 | - port: {{ .Values.service.port }} 14 | targetPort: metrics 15 | protocol: TCP 16 | name: metrics 17 | selector: 18 | app.kubernetes.io/name: {{ include "mongodb-query-exporter.name" . }} 19 | app.kubernetes.io/instance: {{ .Release.Name }} 20 | type: {{ .Values.service.type }} 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ template "mongodb-query-exporter.serviceAccountName" . }} 6 | labels: {{- include "mongodb-query-exporter.labels" . | nindent 4 }} 7 | {{- with .Values.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | {{- end -}} 12 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.serviceMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ include "mongodb-query-exporter.fullname" . }} 6 | labels: {{- merge ( include "mongodb-query-exporter.labels" . | fromYaml) .Values.serviceMonitor.labels | toYaml | nindent 4 }} 7 | {{- with .Values.annotations }} 8 | annotations: 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | {{- if .Values.serviceMonitor.namespace }} 12 | namespace: {{ .Values.serviceMonitor.namespace }} 13 | {{- end }} 14 | spec: 15 | endpoints: 16 | - port: metrics 17 | path: {{ .Values.metricsPath }} 18 | interval: {{ .Values.serviceMonitor.interval }} 19 | scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} 20 | {{- if .Values.serviceMonitor.metricRelabelings }} 21 | metricRelabelings: {{ toYaml .Values.serviceMonitor.metricRelabelings | nindent 4 }} 22 | {{- end }} 23 | 24 | namespaceSelector: 25 | matchNames: 26 | - {{ .Release.Namespace }} 27 | selector: 28 | matchLabels: 29 | app.kubernetes.io/name: {{ include "mongodb-query-exporter.name" . }} 30 | app.kubernetes.io/instance: {{ .Release.Name }} 31 | {{- if .Values.serviceMonitor.targetLabels }} 32 | targetLabels: 33 | {{- range .Values.serviceMonitor.targetLabels }} 34 | - {{ . }} 35 | {{- end }} 36 | {{- end }} 37 | sampleLimit: {{ .Values.serviceMonitor.sampleLimit }} 38 | {{- end }} 39 | -------------------------------------------------------------------------------- /chart/mongodb-query-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | pullPolicy: IfNotPresent 3 | repository: ghcr.io/raffis/mongodb-query-exporter 4 | tag: 5 | 6 | 7 | affinity: {} 8 | 9 | topologySpreadConstraints: [] 10 | 11 | chartLabels: true 12 | 13 | labels: {} 14 | 15 | annotations: {} 16 | 17 | extraArgs: 18 | 19 | fullnameOverride: "" 20 | 21 | imagePullSecrets: [] 22 | 23 | livenessProbe: 24 | httpGet: 25 | path: /healthz 26 | port: metrics 27 | initialDelaySeconds: 10 28 | 29 | 30 | # List of MongoDB servers (Injected as secret env) 31 | mongodb: [] 32 | # - [mongodb[+srv]://][user:pass@]host1[:port1][,host2[:port2],...][/database][?options] 33 | 34 | # The MongoDB query exporter config (required if exstingConfig.name is not set) 35 | config: | 36 | # version: 2.0 37 | # bind: 0.0.0.0:9412 38 | # log: 39 | # encoding: json 40 | # level: info 41 | # development: false 42 | # disableCaller: false 43 | # global: 44 | # queryTimeout: 10 45 | # maxConnection: 3 46 | # defaultCache: 5 47 | # servers: 48 | # - name: main 49 | # uri: mongodb://localhost:27017 #Will be overwritten by the "mongodb" value 50 | # metrics: 51 | # - name: myapp_example_simplevalue_total 52 | # type: gauge #Can also be empty, the default is gauge 53 | # servers: [main] #Can also be empty, if empty the metric will be used for every server defined 54 | # help: 'Simple gauge metric' 55 | # value: total 56 | # labels: [] 57 | # mode: pull 58 | # cache: 0 59 | # constLabels: [] 60 | # database: mydb 61 | # collection: objects 62 | # pipeline: | 63 | # [ 64 | # {"$count":"total"} 65 | # ] 66 | 67 | # Name of an externally managed configmap (in the same namespace) containing the mongodb-query-exporter yaml config 68 | # If this is provided, the value config is ignored. Note the config needs a key named `config.yaml` which contains the query exporters config. 69 | existingConfig: 70 | name: "" 71 | 72 | # Name of an externally managed secret (in the same namespace) containing as list of MongoDB envs (connectin URI) 73 | # If this is provided, the value mongodb is ignored. 74 | existingSecret: 75 | name: "" 76 | 77 | nameOverride: "" 78 | 79 | nodeSelector: {} 80 | 81 | # A list of secrets and their paths to mount inside the pod 82 | # This is useful for mounting certificates for security 83 | secretMounts: [] 84 | # - name: mongodb-certs 85 | # secretName: mongodb-certs 86 | # path: /ssl 87 | 88 | # Add additional containers (sidecars) 89 | extraContainers: 90 | 91 | podAnnotations: {} 92 | # prometheus.io/scrape: "true" 93 | # prometheus.io/port: "metrics" 94 | 95 | port: "9412" 96 | 97 | # Change the metrics path 98 | metricsPath: /metrics 99 | 100 | priorityClassName: "" 101 | 102 | readinessProbe: 103 | httpGet: 104 | path: /healthz 105 | port: metrics 106 | initialDelaySeconds: 10 107 | 108 | replicas: 1 109 | 110 | resources: {} 111 | # limits: 112 | # cpu: 250m 113 | # memory: 192Mi 114 | # requests: 115 | # cpu: 100m 116 | # memory: 128Mi 117 | 118 | # Extra environment variables that will be passed into the exporter pod 119 | env: {} 120 | 121 | ## The name of a secret in the same kubernetes namespace which contain values to be added to the environment 122 | ## This can be useful for auth tokens, etc 123 | envFromSecret: "" 124 | 125 | ## A list of environment variables from secret refs that will be passed into the exporter pod 126 | ## example: 127 | ## extraEnvSecrets: 128 | ## MY_ENV: 129 | ## secret: my-secret 130 | ## key: password 131 | extraEnvSecrets: {} 132 | 133 | ## A list of environment variables from fieldPath refs that will expose pod information to the container 134 | ## This can be useful for enriching the custom metrics with pod information 135 | ## example: 136 | ## extraEnvFieldPath: 137 | ## POD_NAME: metadata.name 138 | extraEnvFieldPath: {} 139 | 140 | securityContext: 141 | allowPrivilegeEscalation: false 142 | capabilities: 143 | drop: ["all"] 144 | readOnlyRootFilesystem: true 145 | runAsGroup: 10000 146 | runAsNonRoot: true 147 | runAsUser: 10000 148 | 149 | service: 150 | enabled: false 151 | labels: {} 152 | annotations: {} 153 | port: 9412 154 | type: ClusterIP 155 | 156 | serviceAccount: 157 | create: true 158 | # If create is true and name is not set, then a name is generated using the 159 | # fullname template. 160 | name: 161 | 162 | # Prometheus operator ServiceMonitor 163 | serviceMonitor: 164 | enabled: false 165 | interval: 30s 166 | scrapeTimeout: 10s 167 | namespace: 168 | labels: {} 169 | targetLabels: [] 170 | metricRelabelings: [] 171 | sampleLimit: 0 172 | 173 | # Prometheus operator PodMonitor 174 | podMonitor: 175 | enabled: false 176 | interval: 30s 177 | scrapeTimeout: 10s 178 | namespace: 179 | labels: {} 180 | targetLabels: [] 181 | metricRelabelings: [] 182 | sampleLimit: 0 183 | 184 | prometheusRule: 185 | ## If true, a PrometheusRule CRD is created for a prometheus operator 186 | ## https://github.com/coreos/prometheus-operator 187 | ## 188 | ## The rules will be processed as Helm template, allowing to set variables in them. 189 | enabled: false 190 | # namespace: monitoring 191 | labels: {} 192 | rules: [] 193 | 194 | tolerations: [] 195 | -------------------------------------------------------------------------------- /cmd/integration_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | io_prometheus_client "github.com/prometheus/client_model/go" 13 | "github.com/prometheus/common/expfmt" 14 | "github.com/testcontainers/testcontainers-go" 15 | "github.com/testcontainers/testcontainers-go/wait" 16 | "github.com/tj/assert" 17 | "go.mongodb.org/mongo-driver/bson" 18 | "go.mongodb.org/mongo-driver/mongo" 19 | "go.mongodb.org/mongo-driver/mongo/options" 20 | ) 21 | 22 | type mongodbContainer struct { 23 | testcontainers.Container 24 | URI string 25 | } 26 | 27 | type integrationTest struct { 28 | name string 29 | configPath string 30 | mongodbImage string 31 | expectedMetrics map[string]string 32 | } 33 | 34 | func TestMetricsConfigv2(t *testing.T) { 35 | expected := map[string]string{ 36 | "myapp_example_simplevalue_total": `name:"myapp_example_simplevalue_total" help:"Simple gauge metric" type:GAUGE metric: label: gauge: > `, 37 | "myapp_example_processes_total": `name:"myapp_example_processes_total" help:"The total number of processes in a job queue" type:GAUGE metric: label: label: gauge: > metric: label: label: gauge: > `, 38 | "myapp_events_total": `name:"myapp_events_total" help:"The total number of events (created 1h ago or newer)" type:GAUGE metric: label: gauge: > metric: label: gauge: > `, 39 | "mongodb_query_exporter_query_total": `name:"mongodb_query_exporter_query_total" help:"How many MongoDB queries have been processed, partitioned by metric, server and status" type:COUNTER metric: label: label: counter: > metric: label: label: counter: > metric: label: label: counter: > `, 40 | } 41 | 42 | tests := []integrationTest{ 43 | { 44 | name: "integration test using config v2.0 and mongodb:5.0", 45 | configPath: "../example/configv2.yaml", 46 | mongodbImage: "mongo:5.0", 47 | expectedMetrics: expected, 48 | }, 49 | { 50 | name: "integration test using config v3.0 and mongodb:4.4", 51 | configPath: "../example/configv3.yaml", 52 | mongodbImage: "mongo:4.4", 53 | expectedMetrics: expected, 54 | }, 55 | { 56 | name: "integration test using config v3.0 and mongodb:5.0", 57 | configPath: "../example/configv3.yaml", 58 | mongodbImage: "mongo:5.0", 59 | expectedMetrics: expected, 60 | }, 61 | { 62 | name: "integration test using config v3.0 and mongodb:6.0", 63 | configPath: "../example/configv3.yaml", 64 | mongodbImage: "mongo:6.0", 65 | expectedMetrics: expected, 66 | }, 67 | } 68 | 69 | for _, test := range tests { 70 | t.Run(test.name, func(t *testing.T) { 71 | executeIntegrationTest(t, test) 72 | }) 73 | } 74 | } 75 | 76 | func executeIntegrationTest(t *testing.T, test integrationTest) { 77 | container, err := setupMongoDBContainer(context.TODO(), test.mongodbImage) 78 | assert.NoError(t, err) 79 | opts := options.Client().ApplyURI(container.URI) 80 | 81 | defer func() { 82 | assert.NoError(t, container.Terminate(context.TODO())) 83 | }() 84 | 85 | client, err := mongo.Connect(context.TODO(), opts) 86 | assert.NoError(t, err) 87 | setupTestData(t, client) 88 | 89 | os.Setenv("MDBEXPORTER_SERVER_0_MONGODB_URI", container.URI) 90 | os.Args = []string{ 91 | "mongodb_query_exporter", 92 | fmt.Sprintf("--file=%s", test.configPath), 93 | } 94 | 95 | go func() { 96 | main() 97 | }() 98 | 99 | //binding is blocking, do this async but wait 200ms for tcp port to be open 100 | time.Sleep(200 * time.Millisecond) 101 | resp, err := http.Get("http://localhost:9412/metrics") 102 | assert.NoError(t, err) 103 | assert.Equal(t, 200, resp.StatusCode) 104 | 105 | d := expfmt.NewDecoder(resp.Body, expfmt.ResponseFormat(resp.Header)) 106 | found := 0 107 | 108 | for { 109 | fam := io_prometheus_client.MetricFamily{} 110 | if err = d.Decode(&fam); err != nil { 111 | break 112 | } 113 | 114 | if val, ok := test.expectedMetrics[*fam.Name]; ok { 115 | found++ 116 | assert.Equal(t, val, fam.String()) 117 | } 118 | } 119 | 120 | assert.Len(t, test.expectedMetrics, found) 121 | 122 | //tear down http server and unregister collector 123 | assert.NoError(t, srv.Shutdown(context.TODO())) 124 | prometheus.Unregister(promCollector) 125 | } 126 | 127 | func setupMongoDBContainer(ctx context.Context, image string) (*mongodbContainer, error) { 128 | req := testcontainers.ContainerRequest{ 129 | Image: image, 130 | ExposedPorts: []string{"27017/tcp"}, 131 | WaitingFor: wait.ForListeningPort("27017"), 132 | Tmpfs: map[string]string{ 133 | "/data/db": "", 134 | }, 135 | } 136 | 137 | container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 138 | ContainerRequest: req, 139 | Started: true, 140 | }) 141 | 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | ip, err := container.Host(ctx) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | mappedPort, err := container.MappedPort(ctx, "27017") 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | uri := fmt.Sprintf("mongodb://%s:%s", ip, mappedPort.Port()) 157 | 158 | return &mongodbContainer{Container: container, URI: uri}, nil 159 | } 160 | 161 | type testRecord struct { 162 | document bson.M 163 | database string 164 | collection string 165 | } 166 | 167 | func setupTestData(t *testing.T, client *mongo.Client) { 168 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 169 | defer cancel() 170 | 171 | testData := []testRecord{ 172 | { 173 | database: "mydb", 174 | collection: "objects", 175 | document: bson.M{ 176 | "foo": "bar", 177 | }, 178 | }, 179 | { 180 | database: "mydb", 181 | collection: "objects", 182 | document: bson.M{ 183 | "foo": "foo", 184 | }, 185 | }, 186 | { 187 | database: "mydb", 188 | collection: "queue", 189 | document: bson.M{ 190 | "class": "foobar", 191 | "status": 1, 192 | }, 193 | }, 194 | { 195 | database: "mydb", 196 | collection: "queue", 197 | document: bson.M{ 198 | "class": "foobar", 199 | "status": 1, 200 | }, 201 | }, 202 | { 203 | database: "mydb", 204 | collection: "queue", 205 | document: bson.M{ 206 | "class": "bar", 207 | "status": 2, 208 | }, 209 | }, 210 | { 211 | database: "mydb", 212 | collection: "events", 213 | document: bson.M{ 214 | "type": "bar", 215 | "created": time.Now(), 216 | }, 217 | }, 218 | { 219 | database: "mydb", 220 | collection: "events", 221 | document: bson.M{ 222 | "type": "bar", 223 | "created": time.Now(), 224 | }, 225 | }, 226 | { 227 | database: "mydb", 228 | collection: "events", 229 | document: bson.M{ 230 | "type": "foo", 231 | "created": time.Now(), 232 | }, 233 | }, 234 | } 235 | 236 | for _, record := range testData { 237 | _, err := client.Database(record.database).Collection(record.collection).InsertOne(ctx, record.document) 238 | assert.NoError(t, err) 239 | } 240 | 241 | } 242 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "os" 8 | "os/user" 9 | "time" 10 | 11 | "github.com/raffis/mongodb-query-exporter/v5/internal/collector" 12 | "github.com/raffis/mongodb-query-exporter/v5/internal/config" 13 | v1 "github.com/raffis/mongodb-query-exporter/v5/internal/config/v1" 14 | v2 "github.com/raffis/mongodb-query-exporter/v5/internal/config/v2" 15 | v3 "github.com/raffis/mongodb-query-exporter/v5/internal/config/v3" 16 | 17 | "github.com/prometheus/client_golang/prometheus" 18 | "github.com/prometheus/client_golang/prometheus/promhttp" 19 | flag "github.com/spf13/pflag" 20 | "github.com/spf13/viper" 21 | ) 22 | 23 | var ( 24 | configPath string 25 | logLevel string 26 | logEncoding string 27 | bind string 28 | uri string 29 | metricsPath string 30 | queryTimeout time.Duration 31 | srv *http.Server 32 | promCollector *collector.Collector 33 | ) 34 | 35 | func init() { 36 | flag.StringVarP(&uri, "uri", "u", config.DefaultMongoDBURI, "MongoDB URI (default is mongodb://localhost:27017). Use MDBEXPORTER_SERVER_%d_MONGODB_URI envs if you target multiple server") 37 | flag.StringVarP(&configPath, "file", "f", "", "config file (default is $HOME/.mongodb_query_exporter/config.yaml)") 38 | flag.StringVarP(&logLevel, "log-level", "l", config.DefaultLogLevel, "Define the log level (default is warning) [debug,info,warn,error]") 39 | flag.StringVarP(&logEncoding, "log-encoding", "e", config.DefaultLogEncoder, "Define the log format (default is json) [json,console]") 40 | flag.StringVarP(&bind, "bind", "b", config.DefaultBindAddr, "Address to bind http server (default is :9412)") 41 | flag.StringVarP(&metricsPath, "path", "p", config.DefaultMetricsPath, "Metric path (default is /metrics)") 42 | flag.DurationVarP(&queryTimeout, "query-timeout", "t", config.DefaultQueryTimeout, "Timeout for MongoDB queries") 43 | 44 | _ = viper.BindPFlag("log.level", flag.Lookup("log-level")) 45 | _ = viper.BindPFlag("log.encoding", flag.Lookup("log-encoding")) 46 | _ = viper.BindPFlag("bind", flag.Lookup("bind")) 47 | _ = viper.BindPFlag("metricsPath", flag.Lookup("path")) 48 | _ = viper.BindPFlag("mongodb.uri", flag.Lookup("uri")) 49 | _ = viper.BindPFlag("mongodb.queryTimeout", flag.Lookup("query-timeout")) 50 | _ = viper.BindEnv("mongodb.uri", "MDBEXPORTER_MONGODB_URI") 51 | _ = viper.BindEnv("global.queryTimeout", "MDBEXPORTER_MONGODB_QUERY_TIMEOUT") 52 | _ = viper.BindEnv("log.level", "MDBEXPORTER_LOG_LEVEL") 53 | _ = viper.BindEnv("log.encoding", "MDBEXPORTER_LOG_ENCODING") 54 | _ = viper.BindEnv("bind", "MDBEXPORTER_BIND") 55 | _ = viper.BindEnv("metricsPath", "MDBEXPORTER_METRICSPATH") 56 | } 57 | 58 | func main() { 59 | flag.Parse() 60 | initConfig() 61 | 62 | c, conf, err := buildCollector() 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | prometheus.MustRegister(c) 68 | promCollector = c 69 | _ = c.StartCacheInvalidator() 70 | srv = buildHTTPServer(prometheus.DefaultGatherer, conf) 71 | err = srv.ListenAndServe() 72 | 73 | // Only panic if we have a net error 74 | if _, ok := err.(*net.OpError); ok { 75 | panic(err) 76 | } else { 77 | os.Stderr.WriteString(err.Error() + "\n") 78 | } 79 | } 80 | 81 | func buildCollector() (*collector.Collector, config.Config, error) { 82 | var configVersion float32 83 | err := viper.UnmarshalKey("version", &configVersion) 84 | if err != nil { 85 | panic(err) 86 | } 87 | 88 | var conf config.Config 89 | switch configVersion { 90 | case 3.0: 91 | conf = &v3.Config{} 92 | 93 | case 2.0: 94 | conf = &v2.Config{} 95 | 96 | default: 97 | conf = &v1.Config{} 98 | } 99 | 100 | err = viper.Unmarshal(&conf) 101 | if err != nil { 102 | panic(err) 103 | } 104 | 105 | if os.Getenv("MDBEXPORTER_MONGODB_URI") != "" { 106 | os.Setenv("MDBEXPORTER_SERVER_0_MONGODB_URI", os.Getenv("MDBEXPORTER_MONGODB_URI")) 107 | } 108 | 109 | if uri != "" && uri != "mongodb://localhost:27017" { 110 | os.Setenv("MDBEXPORTER_SERVER_0_MONGODB_URI", uri) 111 | } 112 | 113 | c, err := conf.Build() 114 | return c, conf, err 115 | } 116 | 117 | // Run executes a blocking http server. Starts the http listener with the metrics and healthz endpoints. 118 | func buildHTTPServer(reg prometheus.Gatherer, conf config.Config) *http.Server { 119 | mux := http.NewServeMux() 120 | 121 | if conf.GetMetricsPath() != "/" { 122 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 123 | http.Error(w, fmt.Sprintf("Use the %s endpoint", conf.GetMetricsPath()), http.StatusOK) 124 | }) 125 | } 126 | 127 | mux.HandleFunc(config.HealthzPath, func(w http.ResponseWriter, r *http.Request) { http.Error(w, "OK", http.StatusOK) }) 128 | mux.HandleFunc(conf.GetMetricsPath(), func(w http.ResponseWriter, r *http.Request) { 129 | promhttp.HandlerFor(reg, promhttp.HandlerOpts{}).ServeHTTP(w, r) 130 | }) 131 | 132 | srv := http.Server{Addr: conf.GetBindAddr(), Handler: mux} 133 | return &srv 134 | } 135 | 136 | func initConfig() { 137 | envPath := os.Getenv("MDBEXPORTER_CONFIG") 138 | 139 | if configPath != "" { 140 | // Use config file from the flag. 141 | viper.SetConfigFile(configPath) 142 | } else if envPath != "" { 143 | // Use config file from env. 144 | viper.SetConfigFile(envPath) 145 | } else { 146 | // Find home directory. 147 | usr, err := user.Current() 148 | if err == nil { 149 | viper.AddConfigPath(usr.HomeDir + "/.mongodb_query_exporter") 150 | } 151 | 152 | // System wide config 153 | viper.AddConfigPath("/etc/mongodb-query-exporter") 154 | viper.AddConfigPath("/etc/mongodb_query_exporter") 155 | } 156 | 157 | viper.SetConfigType("yaml") 158 | if err := viper.ReadInConfig(); err != nil { 159 | panic(err) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /config/base/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: mongodb-query-exporter 5 | spec: 6 | replicas: 1 7 | template: 8 | spec: 9 | containers: 10 | - image: ghcr.io/raffis/mongodb-query-exporter:latest 11 | imagePullPolicy: Never 12 | securityContext: 13 | allowPrivilegeEscalation: false 14 | readOnlyRootFilesystem: true 15 | name: exporter 16 | ports: 17 | - containerPort: 9412 18 | name: http-metrics 19 | protocol: TCP 20 | readinessProbe: 21 | httpGet: 22 | path: /readyz 23 | port: http-metrics 24 | livenessProbe: 25 | httpGet: 26 | path: /healthz 27 | port: http-metrics 28 | resources: 29 | limits: 30 | cpu: 100m 31 | memory: 500Mi 32 | requests: 33 | cpu: 100m 34 | memory: 200Mi 35 | terminationGracePeriodSeconds: 10 36 | -------------------------------------------------------------------------------- /config/base/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - deployment.yaml 5 | - service.yaml 6 | 7 | commonLabels: 8 | app: mongodb-query-exporter 9 | 10 | images: 11 | - name: raffis/mongodb-query-exporter:latest 12 | newTag: 2.0.1 13 | -------------------------------------------------------------------------------- /config/base/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: mongodb-query-exporter 5 | spec: 6 | type: ClusterIP 7 | ports: 8 | - name: http-metrics 9 | port: 80 10 | targetPort: http-metrics -------------------------------------------------------------------------------- /config/tests/base/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - ../../../base 5 | - namespace.yaml -------------------------------------------------------------------------------- /config/tests/base/default/namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: mongo-system 5 | -------------------------------------------------------------------------------- /config/tests/base/mongodb/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | 4 | resources: 5 | - root-secret.yaml 6 | 7 | helmCharts: 8 | - repo: https://charts.bitnami.com/bitnami 9 | name: mongodb 10 | version: 13.10.2 11 | releaseName: mongodb 12 | namespace: mongo-system 13 | valuesInline: 14 | persistence: 15 | enabled: false 16 | auth: 17 | rootPassword: password 18 | -------------------------------------------------------------------------------- /config/tests/base/mongodb/root-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | data: 3 | username: cm9vdA== #root 4 | password: cGFzc3dvcmQ= #password 5 | kind: Secret 6 | metadata: 7 | name: mongodb-credentials -------------------------------------------------------------------------------- /config/tests/cases/mongodb-v5/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: mongodb-query-exporter 5 | data: 6 | config.yaml: | 7 | version: 2.0 8 | metrics: 9 | - name: total_mongodb_users 10 | type: gauge 11 | help: 'Total count of mongodb users' 12 | value: total 13 | overrideEmpty: true 14 | emptyValue: 0 15 | labels: [] 16 | mode: pull 17 | cache: 0 18 | constLabels: [] 19 | database: system 20 | collection: users 21 | pipeline: | 22 | [ 23 | {"$count":"total"} 24 | ] 25 | -------------------------------------------------------------------------------- /config/tests/cases/mongodb-v5/kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | namespace: mongo-system 4 | 5 | resources: 6 | - ../../base/default 7 | - ../../base/mongodb 8 | - configmap.yaml 9 | - verify-get-metrics.yaml 10 | 11 | patches: 12 | - target: 13 | kind: Deployment 14 | name: mongodb-query-exporter 15 | patch: |- 16 | apiVersion: apps/v1 17 | kind: Deployment 18 | metadata: 19 | name: patch 20 | spec: 21 | template: 22 | spec: 23 | containers: 24 | - name: exporter 25 | env: 26 | - name: MDBEXPORTER_MONGODB_URI 27 | value: mongodb://${USERNAME}:${PASSWORD}@mongodb.mongo-system:27017 28 | - name: USERNAME 29 | valueFrom: 30 | secretKeyRef: 31 | name: mongodb-credentials 32 | key: username 33 | - name: PASSWORD 34 | valueFrom: 35 | secretKeyRef: 36 | name: mongodb-credentials 37 | key: password 38 | volumeMounts: 39 | - name: config 40 | mountPath: /etc/mongodb-query-exporter/config.yaml 41 | subPath: config.yaml 42 | volumes: 43 | - name: config 44 | configMap: 45 | name: mongodb-query-exporter -------------------------------------------------------------------------------- /config/tests/cases/mongodb-v5/verify-get-metrics.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: verify-get-features-clienttoken 5 | labels: 6 | verify: yes 7 | spec: 8 | restartPolicy: OnFailure 9 | containers: 10 | - image: curlimages/curl:8.1.2 11 | imagePullPolicy: IfNotPresent 12 | name: verify 13 | command: 14 | - /bin/sh 15 | - "-c" 16 | - | 17 | curl --fail -vvv http://mongodb-query-exporter/metrics | grep 'total_mongodb_users' 18 | resources: {} 19 | securityContext: 20 | allowPrivilegeEscalation: false 21 | readOnlyRootFilesystem: false 22 | runAsGroup: 1000 23 | runAsNonRoot: true 24 | runAsUser: 1000 25 | terminationMessagePath: /dev/termination-log 26 | terminationMessagePolicy: File 27 | -------------------------------------------------------------------------------- /example/configv1.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | bind: 0.0.0.0:9412 3 | logLevel: info 4 | mongodb: 5 | uri: mongodb://localhost:27017 6 | connectionTimeout: 3 7 | maxConnection: 3 8 | defaultInterval: 5 9 | metrics: 10 | - name: myapp_example_simplevalue_total 11 | type: gauge #Can also be empty, the default is gauge 12 | help: 'Simple gauge metric' 13 | value: total 14 | overrideEmpty: true # if an empty result set is returned.. 15 | emptyValue: 0 # create a metric with value 0 16 | labels: [] 17 | mode: pull 18 | cache: 0 19 | constLabels: 20 | region: eu-central-1 21 | database: mydb 22 | collection: objects 23 | pipeline: | 24 | [ 25 | {"$count":"total"} 26 | ] 27 | - name: myapp_example_processes_total 28 | type: gauge 29 | help: 'The total number of processes in a job queue' 30 | value: total 31 | mode: pull 32 | labels: [type,status] 33 | constLabels: {} 34 | database: mydb 35 | collection: queue 36 | pipeline: | 37 | [ 38 | {"$group": { 39 | "_id":{"status":"$status","name":"$class"}, 40 | "total":{"$sum":1} 41 | }}, 42 | {"$project":{ 43 | "_id":0, 44 | "type":"$_id.name", 45 | "total":"$total", 46 | "status": { 47 | "$switch": { 48 | "branches": [ 49 | { "case": { "$eq": ["$_id.status", 0] }, "then": "waiting" }, 50 | { "case": { "$eq": ["$_id.status", 1] }, "then": "postponed" }, 51 | { "case": { "$eq": ["$_id.status", 2] }, "then": "processing" }, 52 | { "case": { "$eq": ["$_id.status", 3] }, "then": "done" }, 53 | { "case": { "$eq": ["$_id.status", 4] }, "then": "failed" }, 54 | { "case": { "$eq": ["$_id.status", 5] }, "then": "canceled" }, 55 | { "case": { "$eq": ["$_id.status", 6] }, "then": "timeout" } 56 | ], 57 | "default": "unknown" 58 | }} 59 | }} 60 | ] 61 | - name: myapp_events_total 62 | type: gauge 63 | help: 'The total number of events (created 1h ago or newer)' 64 | value: count 65 | mode: pull 66 | labels: [type] 67 | constLabels: {} 68 | database: mydb 69 | collection: events 70 | # Note $$NOW is only supported in MongoDB >= 4.2 71 | pipeline: | 72 | [ 73 | { "$sort": { "created": -1 }}, 74 | {"$limit": 100000}, 75 | {"$match":{ 76 | "$expr": { 77 | "$gte": [ 78 | "$created", 79 | { 80 | "$subtract": ["$$NOW", 3600000] 81 | } 82 | ] 83 | } 84 | }}, 85 | {"$group": { 86 | "_id":{"type":"$type"}, 87 | "count":{"$sum":1} 88 | }}, 89 | {"$project":{ 90 | "_id":0, 91 | "type":"$_id.type", 92 | "count":"$count" 93 | }} 94 | ] 95 | -------------------------------------------------------------------------------- /example/configv2.yaml: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | bind: 0.0.0.0:9412 3 | metricsPath: /metrics 4 | log: 5 | encoding: json 6 | level: info 7 | development: false 8 | disableCaller: false 9 | global: 10 | queryTimeout: "10m" 11 | maxConnection: 3 12 | defaultCache: 5 13 | servers: 14 | - name: main 15 | uri: mongodb://localhost:27017 16 | metrics: 17 | - name: myapp_example_simplevalue_total 18 | type: gauge #Can also be empty, the default is gauge 19 | servers: [main] #Can also be empty, if empty the metric will be used for every server defined 20 | help: 'Simple gauge metric' 21 | value: total 22 | overrideEmpty: true # if an empty result set is returned.. 23 | emptyValue: 0 # create a metric with value 0 24 | labels: [] 25 | mode: pull 26 | cache: 0 27 | constLabels: 28 | region: eu-central-1 29 | database: mydb 30 | collection: objects 31 | pipeline: | 32 | [ 33 | {"$count":"total"} 34 | ] 35 | - name: myapp_example_processes_total 36 | type: gauge 37 | help: 'The total number of processes in a job queue' 38 | value: total 39 | mode: pull 40 | labels: [type,status] 41 | constLabels: {} 42 | database: mydb 43 | collection: queue 44 | pipeline: | 45 | [ 46 | {"$group": { 47 | "_id":{"status":"$status","name":"$class"}, 48 | "total":{"$sum":1} 49 | }}, 50 | {"$project":{ 51 | "_id":0, 52 | "type":"$_id.name", 53 | "total":"$total", 54 | "status": { 55 | "$switch": { 56 | "branches": [ 57 | { "case": { "$eq": ["$_id.status", 0] }, "then": "waiting" }, 58 | { "case": { "$eq": ["$_id.status", 1] }, "then": "postponed" }, 59 | { "case": { "$eq": ["$_id.status", 2] }, "then": "processing" }, 60 | { "case": { "$eq": ["$_id.status", 3] }, "then": "done" }, 61 | { "case": { "$eq": ["$_id.status", 4] }, "then": "failed" }, 62 | { "case": { "$eq": ["$_id.status", 5] }, "then": "canceled" }, 63 | { "case": { "$eq": ["$_id.status", 6] }, "then": "timeout" } 64 | ], 65 | "default": "unknown" 66 | }} 67 | }} 68 | ] 69 | - name: myapp_events_total 70 | type: gauge 71 | help: 'The total number of events (created 1h ago or newer)' 72 | value: count 73 | mode: pull 74 | labels: [type] 75 | constLabels: {} 76 | database: mydb 77 | collection: events 78 | # Note $$NOW is only supported in MongoDB >= 4.2 79 | pipeline: | 80 | [ 81 | { "$sort": { "created": -1 }}, 82 | {"$limit": 100000}, 83 | {"$match":{ 84 | "$expr": { 85 | "$gte": [ 86 | "$created", 87 | { 88 | "$subtract": ["$$NOW", 3600000] 89 | } 90 | ] 91 | } 92 | }}, 93 | {"$group": { 94 | "_id":{"type":"$type"}, 95 | "count":{"$sum":1} 96 | }}, 97 | {"$project":{ 98 | "_id":0, 99 | "type":"$_id.type", 100 | "count":"$count" 101 | }} 102 | ] 103 | -------------------------------------------------------------------------------- /example/configv3.yaml: -------------------------------------------------------------------------------- 1 | version: 3.0 2 | bind: 0.0.0.0:9412 3 | metricsPath: /metrics 4 | log: 5 | encoding: json 6 | level: info 7 | development: false 8 | disableCaller: false 9 | global: 10 | queryTimeout: "10s" 11 | maxConnection: 3 12 | defaultCache: 0 13 | servers: 14 | - name: main 15 | uri: mongodb://localhost:27017 16 | aggregations: 17 | - database: mydb 18 | collection: objects 19 | servers: [main] #Can also be empty, if empty the metric will be used for every server defined 20 | metrics: 21 | - name: myapp_example_simplevalue_total 22 | type: gauge #Can also be empty, the default is gauge 23 | help: 'Simple gauge metric' 24 | value: total 25 | overrideEmpty: true # if an empty result set is returned.. 26 | emptyValue: 0 # create a metric with value 0 27 | labels: [] 28 | constLabels: 29 | region: eu-central-1 30 | mode: pull 31 | pipeline: | 32 | [ 33 | {"$count":"total"} 34 | ] 35 | - database: mydb 36 | collection: queue 37 | metrics: 38 | - name: myapp_example_processes_total 39 | type: gauge 40 | help: 'The total number of processes in a job queue' 41 | value: total 42 | labels: [type,status] 43 | constLabels: {} 44 | mode: pull 45 | cache: "5m" 46 | pipeline: | 47 | [ 48 | {"$group": { 49 | "_id":{"status":"$status","name":"$class"}, 50 | "total":{"$sum":1} 51 | }}, 52 | {"$project":{ 53 | "_id":0, 54 | "type":"$_id.name", 55 | "total":"$total", 56 | "status": { 57 | "$switch": { 58 | "branches": [ 59 | { "case": { "$eq": ["$_id.status", 0] }, "then": "waiting" }, 60 | { "case": { "$eq": ["$_id.status", 1] }, "then": "postponed" }, 61 | { "case": { "$eq": ["$_id.status", 2] }, "then": "processing" }, 62 | { "case": { "$eq": ["$_id.status", 3] }, "then": "done" }, 63 | { "case": { "$eq": ["$_id.status", 4] }, "then": "failed" }, 64 | { "case": { "$eq": ["$_id.status", 5] }, "then": "canceled" }, 65 | { "case": { "$eq": ["$_id.status", 6] }, "then": "timeout" } 66 | ], 67 | "default": "unknown" 68 | }} 69 | }} 70 | ] 71 | - database: mydb 72 | collection: events 73 | metrics: 74 | - name: myapp_events_total 75 | type: gauge 76 | help: 'The total number of events (created 1h ago or newer)' 77 | value: count 78 | labels: [type] 79 | constLabels: {} 80 | mode: pull 81 | # Note $$NOW is only supported in MongoDB >= 4.2 82 | pipeline: | 83 | [ 84 | { "$sort": { "created": -1 }}, 85 | {"$limit": 100000}, 86 | {"$match":{ 87 | "$expr": { 88 | "$gte": [ 89 | "$created", 90 | { 91 | "$subtract": ["$$NOW", 3600000] 92 | } 93 | ] 94 | } 95 | }}, 96 | {"$group": { 97 | "_id":{"type":"$type"}, 98 | "count":{"$sum":1} 99 | }}, 100 | {"$project":{ 101 | "_id":0, 102 | "type":"$_id.type", 103 | "count":"$count" 104 | }} 105 | ] 106 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/raffis/mongodb-query-exporter/v5 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/hashicorp/go-multierror v1.1.1 7 | github.com/pkg/errors v0.9.1 8 | github.com/prometheus/client_golang v1.16.0 9 | github.com/prometheus/client_model v0.3.0 10 | github.com/prometheus/common v0.42.0 11 | github.com/spf13/pflag v1.0.5 12 | github.com/spf13/viper v1.17.0 13 | github.com/testcontainers/testcontainers-go v0.26.0 14 | github.com/tj/assert v0.0.3 15 | go.mongodb.org/mongo-driver v1.12.1 16 | go.uber.org/zap v1.26.0 17 | ) 18 | 19 | require ( 20 | dario.cat/mergo v1.0.0 // indirect 21 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 22 | github.com/Microsoft/go-winio v0.6.1 // indirect 23 | github.com/Microsoft/hcsshim v0.11.1 // indirect 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 26 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 27 | github.com/containerd/containerd v1.7.7 // indirect 28 | github.com/containerd/log v0.1.0 // indirect 29 | github.com/cpuguy83/dockercfg v0.3.1 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/docker/distribution v2.8.2+incompatible // indirect 32 | github.com/docker/docker v24.0.6+incompatible // indirect 33 | github.com/docker/go-connections v0.4.0 // indirect 34 | github.com/docker/go-units v0.5.0 // indirect 35 | github.com/fsnotify/fsnotify v1.6.0 // indirect 36 | github.com/go-ole/go-ole v1.2.6 // indirect 37 | github.com/gogo/protobuf v1.3.2 // indirect 38 | github.com/golang/protobuf v1.5.3 // indirect 39 | github.com/golang/snappy v0.0.1 // indirect 40 | github.com/google/uuid v1.3.1 // indirect 41 | github.com/hashicorp/errwrap v1.1.0 // indirect 42 | github.com/hashicorp/hcl v1.0.0 // indirect 43 | github.com/klauspost/compress v1.17.0 // indirect 44 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 45 | github.com/magiconair/properties v1.8.7 // indirect 46 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 47 | github.com/mitchellh/mapstructure v1.5.0 // indirect 48 | github.com/moby/patternmatcher v0.6.0 // indirect 49 | github.com/moby/sys/sequential v0.5.0 // indirect 50 | github.com/moby/term v0.5.0 // indirect 51 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect 52 | github.com/morikuni/aec v1.0.0 // indirect 53 | github.com/opencontainers/go-digest v1.0.0 // indirect 54 | github.com/opencontainers/image-spec v1.1.0-rc5 // indirect 55 | github.com/opencontainers/runc v1.1.5 // indirect 56 | github.com/pelletier/go-toml/v2 v2.1.0 // indirect 57 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 58 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 59 | github.com/prometheus/procfs v0.10.1 // indirect 60 | github.com/sagikazarmark/locafero v0.3.0 // indirect 61 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 62 | github.com/shirou/gopsutil/v3 v3.23.9 // indirect 63 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 64 | github.com/sirupsen/logrus v1.9.3 // indirect 65 | github.com/sourcegraph/conc v0.3.0 // indirect 66 | github.com/spf13/afero v1.10.0 // indirect 67 | github.com/spf13/cast v1.5.1 // indirect 68 | github.com/stretchr/testify v1.8.4 // indirect 69 | github.com/subosito/gotenv v1.6.0 // indirect 70 | github.com/tklauser/go-sysconf v0.3.12 // indirect 71 | github.com/tklauser/numcpus v0.6.1 // indirect 72 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 73 | github.com/xdg-go/scram v1.1.2 // indirect 74 | github.com/xdg-go/stringprep v1.0.4 // indirect 75 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect 76 | github.com/yusufpapurcu/wmi v1.2.3 // indirect 77 | go.uber.org/multierr v1.10.0 // indirect 78 | golang.org/x/crypto v0.14.0 // indirect 79 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 80 | golang.org/x/mod v0.12.0 // indirect 81 | golang.org/x/net v0.17.0 // indirect 82 | golang.org/x/sync v0.3.0 // indirect 83 | golang.org/x/sys v0.13.0 // indirect 84 | golang.org/x/text v0.13.0 // indirect 85 | golang.org/x/tools v0.13.0 // indirect 86 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230920204549-e6e6cdab5c13 // indirect 87 | google.golang.org/grpc v1.58.2 // indirect 88 | google.golang.org/protobuf v1.31.0 // indirect 89 | gopkg.in/ini.v1 v1.67.0 // indirect 90 | gopkg.in/yaml.v3 v3.0.1 // indirect 91 | ) 92 | -------------------------------------------------------------------------------- /internal/collector/collector.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | multierror "github.com/hashicorp/go-multierror" 10 | "github.com/pkg/errors" 11 | "github.com/prometheus/client_golang/prometheus" 12 | "go.mongodb.org/mongo-driver/bson" 13 | ) 14 | 15 | // A collector is a metric collector group for one single MongoDB server. 16 | // Each collector needs a MongoDB client and a list of metrics which should be generated. 17 | // You may initialize multiple collectors for multiple MongoDB servers. 18 | type Collector struct { 19 | servers []*server 20 | logger Logger 21 | config *Config 22 | aggregations []*Aggregation 23 | counter *prometheus.CounterVec 24 | cache map[string]*cacheEntry 25 | mutex *sync.Mutex 26 | } 27 | 28 | // A cached metric consists of the metric and a ttl in seconds 29 | type cacheEntry struct { 30 | m []prometheus.Metric 31 | ttl int64 32 | } 33 | 34 | // A server needs a driver (implementation) and a unique name 35 | type server struct { 36 | name string 37 | driver Driver 38 | } 39 | 40 | type option func(c *Collector) 41 | 42 | // Collector configuration with default metric configurations 43 | type Config struct { 44 | QueryTimeout time.Duration 45 | DefaultCache time.Duration 46 | DefaultMode string 47 | DefaultDatabase string 48 | DefaultCollection string 49 | } 50 | 51 | // Aggregation defines what aggregation pipeline is executed on what servers 52 | type Aggregation struct { 53 | Servers []string 54 | Cache time.Duration 55 | Mode string 56 | Database string 57 | Collection string 58 | Pipeline string 59 | Metrics []*Metric 60 | pipeline bson.A 61 | } 62 | 63 | // A metric defines how a certain value is exported from a MongoDB aggregation 64 | type Metric struct { 65 | Name string 66 | Type string 67 | Help string 68 | Value string 69 | OverrideEmpty bool 70 | EmptyValue int64 71 | ConstLabels prometheus.Labels 72 | Labels []string 73 | desc *prometheus.Desc 74 | } 75 | 76 | var ( 77 | //Only Gauge is a supported metric types 78 | ErrInvalidType = errors.New("unknown metric type provided. Only gauge is supported") 79 | //The value was not found in the aggregation result set 80 | ErrValueNotFound = errors.New("value not found in result set") 81 | //No cached metric available 82 | ErrNotCached = errors.New("metric not available from cache") 83 | ) 84 | 85 | const ( 86 | //Gauge metric type (Can increase and decrease) 87 | TypeGauge = "gauge" 88 | //Pull mode (with interval) 89 | ModePull = "pull" 90 | //Push mode (Uses changestream which is only supported with MongoDB >= 3.6) 91 | ModePush = "push" 92 | //Metric generated successfully 93 | ResultSuccess = "SUCCESS" 94 | //Metric value could not been determined 95 | ResultError = "ERROR" 96 | ) 97 | 98 | // Create a new collector 99 | func New(opts ...option) *Collector { 100 | c := &Collector{ 101 | logger: &dummyLogger{}, 102 | config: &Config{ 103 | QueryTimeout: 10 * time.Second, 104 | }, 105 | } 106 | 107 | c.cache = make(map[string]*cacheEntry) 108 | c.mutex = &sync.Mutex{} 109 | 110 | for _, opt := range opts { 111 | opt(c) 112 | } 113 | 114 | return c 115 | } 116 | 117 | // Pass a counter metrics about query stats 118 | func WithCounter(m *prometheus.CounterVec) option { 119 | return func(c *Collector) { 120 | c.counter = m 121 | } 122 | } 123 | 124 | // Pass a logger to the collector 125 | func WithLogger(l Logger) option { 126 | return func(c *Collector) { 127 | c.logger = l 128 | } 129 | } 130 | 131 | // Pass a collector configuration (Defaults for metrics) 132 | func WithConfig(conf *Config) option { 133 | return func(c *Collector) { 134 | c.config = conf 135 | } 136 | } 137 | 138 | // Run metric c for each metric either in push or pull mode 139 | func (c *Collector) RegisterServer(name string, driver Driver) error { 140 | for _, srv := range c.servers { 141 | if srv.name == name { 142 | return fmt.Errorf("server %s is already registered", name) 143 | } 144 | } 145 | 146 | srv := &server{ 147 | name: name, 148 | driver: driver, 149 | } 150 | 151 | c.servers = append(c.servers, srv) 152 | return nil 153 | } 154 | 155 | // Run metric c for each metric either in push or pull mode 156 | func (c *Collector) RegisterAggregation(aggregation *Aggregation) error { 157 | if len(aggregation.Servers) != 0 && len(aggregation.Servers) != len(c.GetServers(aggregation.Servers)) { 158 | return fmt.Errorf("aggregation bound to server which have not been found") 159 | } 160 | 161 | err := bson.UnmarshalExtJSON([]byte(aggregation.Pipeline), false, &aggregation.pipeline) 162 | if err != nil { 163 | return errors.Wrap(err, "failed to decode json aggregation pipeline") 164 | } 165 | 166 | if c.config.DefaultCache > 0 && aggregation.Cache != 0 { 167 | aggregation.Cache = c.config.DefaultCache 168 | } 169 | 170 | for _, metric := range aggregation.Metrics { 171 | c.logger.Debugf("register metric %s", metric.Name) 172 | metric.desc = c.describeMetric(metric) 173 | } 174 | 175 | c.aggregations = append(c.aggregations, aggregation) 176 | return nil 177 | } 178 | 179 | // Create prometheus descriptor 180 | func (c *Collector) describeMetric(metric *Metric) *prometheus.Desc { 181 | return prometheus.NewDesc( 182 | metric.Name, 183 | metric.Help, 184 | append([]string{"server"}, metric.Labels...), 185 | metric.ConstLabels, 186 | ) 187 | } 188 | 189 | // Return registered drivers 190 | // You may provide a list of names to only return matching drivers by name 191 | func (c *Collector) GetServers(names []string) []*server { 192 | var servers []*server 193 | for _, srv := range c.servers { 194 | //if we have no filter given just add all drivers to be returned 195 | if len(names) == 0 { 196 | servers = append(servers, srv) 197 | continue 198 | } 199 | 200 | for _, name := range names { 201 | if srv.name == name { 202 | servers = append(servers, srv) 203 | } 204 | } 205 | } 206 | 207 | return servers 208 | } 209 | 210 | // Describe is implemented with DescribeByCollect 211 | func (c *Collector) Describe(ch chan<- *prometheus.Desc) { 212 | if c.counter != nil { 213 | c.counter.Describe(ch) 214 | } 215 | 216 | for _, aggregation := range c.aggregations { 217 | for _, metric := range aggregation.Metrics { 218 | ch <- metric.desc 219 | } 220 | } 221 | } 222 | 223 | // Collect all metrics from queries 224 | func (c *Collector) Collect(ch chan<- prometheus.Metric) { 225 | c.logger.Debugf("start collecting metrics") 226 | var wg sync.WaitGroup 227 | 228 | for i, aggregation := range c.aggregations { 229 | for _, srv := range c.GetServers(aggregation.Servers) { 230 | metrics, err := c.getCached(aggregation, srv) 231 | 232 | if err == nil { 233 | c.logger.Debugf("use value from cache for %s", aggregation.Pipeline) 234 | 235 | for _, m := range metrics { 236 | ch <- m 237 | } 238 | continue 239 | } 240 | 241 | wg.Add(1) 242 | go func(i int, aggregation *Aggregation, srv *server, ch chan<- prometheus.Metric) { 243 | defer wg.Done() 244 | err := c.aggregate(aggregation, srv, ch) 245 | 246 | if c.counter == nil { 247 | return 248 | } 249 | 250 | var result string 251 | if err == nil { 252 | result = ResultSuccess 253 | } else { 254 | c.logger.Errorf("failed to generate metric", "err", err, "name", srv.name) 255 | 256 | result = ResultError 257 | } 258 | 259 | c.counter.With(prometheus.Labels{ 260 | "server": srv.name, 261 | "aggregation": fmt.Sprintf("aggregation_%d", i), 262 | "result": result, 263 | }).Inc() 264 | }(i, aggregation, srv, ch) 265 | } 266 | } 267 | 268 | wg.Wait() 269 | 270 | if c.counter != nil { 271 | c.counter.Collect(ch) 272 | } 273 | } 274 | 275 | func (c *Collector) updateCache(aggregation *Aggregation, srv *server, m []prometheus.Metric) { 276 | var ttl int64 277 | 278 | if (aggregation.Mode == ModePush && aggregation.Cache == 0) || aggregation.Cache == -1 { 279 | c.logger.Debugf("cache metrics from aggregation %s until new push", aggregation.Pipeline) 280 | ttl = -1 281 | 282 | } else if aggregation.Cache > 0 { 283 | c.logger.Debugf("cache metris from aggregation %s for %d", aggregation.Pipeline, aggregation.Cache) 284 | ttl = time.Now().Unix() + int64(aggregation.Cache.Seconds()) 285 | } else { 286 | c.logger.Debugf("skip caching metrics from aggregation %s", aggregation.Pipeline) 287 | return 288 | } 289 | 290 | c.mutex.Lock() 291 | defer c.mutex.Unlock() 292 | 293 | c.cache[aggregation.Pipeline+srv.name] = &cacheEntry{m, ttl} 294 | } 295 | 296 | func (c *Collector) getCached(aggregation *Aggregation, srv *server) ([]prometheus.Metric, error) { 297 | c.mutex.Lock() 298 | defer c.mutex.Unlock() 299 | 300 | if e, exists := c.cache[aggregation.Pipeline+srv.name]; exists { 301 | if e.ttl == -1 || e.ttl >= time.Now().Unix() { 302 | return e.m, nil 303 | } 304 | 305 | // entry can be removed from cache since its expired 306 | delete(c.cache, aggregation.Pipeline+srv.name) 307 | } 308 | 309 | return nil, ErrNotCached 310 | } 311 | 312 | // Start MongoDB watchers for metrics where push is enabled. 313 | // As soon as a new event is registered the cache gets invalidated and the aggregation 314 | // will be re evaluated during the next scrape. 315 | // This is a non blocking operation. 316 | func (c *Collector) StartCacheInvalidator() error { 317 | for _, aggregation := range c.aggregations { 318 | if aggregation.Mode != ModePush { 319 | continue 320 | } 321 | 322 | for _, srv := range c.GetServers(aggregation.Servers) { 323 | go func(aggregation *Aggregation, srv *server) { 324 | err := c.pushUpdate(aggregation, srv) 325 | 326 | if err != nil { 327 | c.logger.Errorf("%s; failed to watch for updates, fallback to pull", err) 328 | } 329 | }(aggregation, srv) 330 | } 331 | } 332 | 333 | return nil 334 | } 335 | 336 | func (c *Collector) pushUpdate(aggregation *Aggregation, srv *server) error { 337 | ctx := context.Background() 338 | 339 | c.logger.Infof("start changestream on %s.%s, waiting for changes", aggregation.Database, aggregation.Collection) 340 | cursor, err := srv.driver.Watch(ctx, aggregation.Database, aggregation.Collection, bson.A{}) 341 | 342 | if err != nil { 343 | return fmt.Errorf("failed to start changestream listener %s", err) 344 | } 345 | 346 | defer cursor.Close(ctx) 347 | 348 | for cursor.Next(context.TODO()) { 349 | var result ChangeStreamEvent 350 | 351 | err := cursor.Decode(&result) 352 | if err != nil { 353 | c.logger.Errorf("failed decode record %s", err) 354 | continue 355 | } 356 | 357 | //Invalidate cached entry, aggregation must be executed during the next scrape 358 | c.mutex.Lock() 359 | delete(c.cache, aggregation.Pipeline+srv.name) 360 | c.mutex.Unlock() 361 | } 362 | 363 | return nil 364 | } 365 | 366 | func (c *Collector) aggregate(aggregation *Aggregation, srv *server, ch chan<- prometheus.Metric) error { 367 | c.logger.Debugf("run aggregation %s on server %s", aggregation.Pipeline, srv.name) 368 | 369 | ctx, cancel := context.WithTimeout(context.Background(), c.config.QueryTimeout) 370 | defer cancel() 371 | 372 | cursor, err := srv.driver.Aggregate(ctx, aggregation.Database, aggregation.Collection, aggregation.pipeline) 373 | if err != nil { 374 | return err 375 | } 376 | 377 | var multierr *multierror.Error 378 | var i int 379 | var result = make(AggregationResult) 380 | var metrics []prometheus.Metric 381 | 382 | for cursor.Next(ctx) { 383 | i++ 384 | 385 | err := cursor.Decode(&result) 386 | c.logger.Debugf("found record %s from aggregation %s", result, aggregation.Pipeline) 387 | 388 | if err != nil { 389 | multierr = multierror.Append(multierr, err) 390 | c.logger.Errorf("failed decode record %s", err) 391 | continue 392 | } 393 | 394 | for _, metric := range aggregation.Metrics { 395 | m, err := createMetric(srv, metric, result) 396 | if err != nil { 397 | return err 398 | } 399 | 400 | metrics = append(metrics, m) 401 | ch <- m 402 | } 403 | } 404 | 405 | if i == 0 { 406 | for _, metric := range aggregation.Metrics { 407 | if !metric.OverrideEmpty { 408 | c.logger.Debugf("skip metric %s with an empty result from aggregation %s", metric.Name, aggregation.Pipeline) 409 | continue 410 | } 411 | 412 | result[metric.Value] = int64(metric.EmptyValue) 413 | for _, label := range metric.Labels { 414 | result[label] = "" 415 | } 416 | 417 | m, err := createMetric(srv, metric, result) 418 | if err != nil { 419 | return err 420 | } 421 | 422 | ch <- m 423 | } 424 | } 425 | 426 | c.updateCache(aggregation, srv, metrics) 427 | return multierr.ErrorOrNil() 428 | } 429 | 430 | func createMetric(srv *server, metric *Metric, result AggregationResult) (prometheus.Metric, error) { 431 | var ( 432 | value float64 433 | err error 434 | ) 435 | 436 | if metric.Value == "" && metric.OverrideEmpty { 437 | value = float64(metric.EmptyValue) 438 | } else { 439 | value, err = metric.getValue(result) 440 | } 441 | 442 | if err != nil { 443 | return nil, err 444 | } 445 | 446 | labels, err := metric.getLabels(result) 447 | if err != nil { 448 | return nil, err 449 | } 450 | 451 | labels = append([]string{srv.name}, labels...) 452 | return prometheus.NewConstMetric(metric.desc, prometheus.GaugeValue, value, labels...) 453 | } 454 | 455 | func (metric *Metric) getValue(result AggregationResult) (float64, error) { 456 | if val, ok := result[metric.Value]; ok { 457 | switch v := val.(type) { 458 | case float32: 459 | value := float64(v) 460 | return value, nil 461 | case float64: 462 | return v, nil 463 | case int32: 464 | value := float64(v) 465 | return value, nil 466 | case int64: 467 | value := float64(v) 468 | return value, nil 469 | default: 470 | return 0, fmt.Errorf("provided value taken from the aggregation result has to be a number, type %T given", val) 471 | } 472 | } 473 | 474 | return 0, ErrValueNotFound 475 | } 476 | 477 | func (metric *Metric) getLabels(result AggregationResult) ([]string, error) { 478 | var labels []string 479 | 480 | for _, label := range metric.Labels { 481 | if val, ok := result[label]; ok { 482 | switch v := val.(type) { 483 | case string: 484 | labels = append(labels, v) 485 | default: 486 | return labels, fmt.Errorf("provided label value taken from the aggregation result has to be a string, type %T given", val) 487 | } 488 | } else { 489 | return labels, fmt.Errorf("required label %s not found in result set", label) 490 | } 491 | } 492 | 493 | return labels, nil 494 | } 495 | -------------------------------------------------------------------------------- /internal/collector/collector_test.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/client_golang/prometheus/testutil" 10 | "github.com/tj/assert" 11 | ) 12 | 13 | func buildMockDriver(docs []interface{}) *mockMongoDBDriver { 14 | return &mockMongoDBDriver{ 15 | AggregateCursor: &mockCursor{ 16 | Data: docs, 17 | }, 18 | } 19 | } 20 | 21 | type aggregationTest struct { 22 | name string 23 | counter bool 24 | aggregation *Aggregation 25 | error string 26 | expected string 27 | expectedCached string 28 | docs []interface{} 29 | } 30 | 31 | func TestInitializeMetrics(t *testing.T) { 32 | var tests = []aggregationTest{ 33 | { 34 | name: "Metric with no type should fail in unsupported metric type", 35 | aggregation: &Aggregation{ 36 | Metrics: []*Metric{ 37 | { 38 | Name: "simple_unlabled_notype", 39 | }, 40 | }, 41 | }, 42 | error: "failed to initialize metric simple_unlabled_notype with error unknown metric type provided. Only [gauge] are valid options", 43 | }, 44 | { 45 | name: "Metric with invalid type should fail in unsupported metric type", 46 | aggregation: &Aggregation{ 47 | Metrics: []*Metric{ 48 | { 49 | Name: "simple_unlabled_invalidtype", 50 | Type: "notexists", 51 | }, 52 | }, 53 | }, 54 | error: "failed to initialize metric simple_unlabled_invalidtype with error unknown metric type provided. Only [gauge] are valid options", 55 | }, 56 | { 57 | name: "Invalid aggregation pipeline must end in error", 58 | aggregation: &Aggregation{ 59 | Metrics: []*Metric{ 60 | { 61 | Name: "simple_gauge_no_pipeline", 62 | Type: "gauge", 63 | }, 64 | }, 65 | Pipeline: "{", 66 | }, 67 | error: "failed to decode json aggregation pipeline: invalid JSON input", 68 | }, 69 | { 70 | name: "Constant labeled gauge and valid value results in a success", 71 | aggregation: &Aggregation{ 72 | Metrics: []*Metric{ 73 | { 74 | Name: "simple", 75 | Type: "gauge", 76 | Value: "total", 77 | Help: "foobar", 78 | ConstLabels: prometheus.Labels{"foo": "bar"}, 79 | }, 80 | }, 81 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 82 | }, 83 | docs: []interface{}{AggregationResult{ 84 | "total": float64(1), 85 | }}, 86 | expected: ` 87 | # HELP simple foobar 88 | # TYPE simple gauge 89 | simple{foo="bar",server="main"} 1 90 | `, 91 | }, 92 | { 93 | name: "Unlabeled gauge and valid value results in a success", 94 | aggregation: &Aggregation{ 95 | Metrics: []*Metric{ 96 | { 97 | Name: "simple", 98 | Type: "gauge", 99 | Value: "total", 100 | Help: "foobar", 101 | }, 102 | }, 103 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 104 | }, 105 | docs: []interface{}{AggregationResult{ 106 | "total": float64(2), 107 | }}, 108 | expected: ` 109 | # HELP simple foobar 110 | # TYPE simple gauge 111 | simple{server="main"} 2 112 | `, 113 | }, 114 | { 115 | name: "Unlabeled gauge and valid value results in a success including successful counter", 116 | counter: true, 117 | aggregation: &Aggregation{ 118 | Metrics: []*Metric{ 119 | { 120 | Name: "simple", 121 | Type: "gauge", 122 | Value: "total", 123 | Help: "foobar", 124 | }, 125 | }, 126 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 127 | }, 128 | docs: []interface{}{AggregationResult{ 129 | "total": float64(2), 130 | }}, 131 | expected: ` 132 | # HELP counter_total mongodb query stats 133 | # TYPE counter_total counter 134 | counter_total{aggregation="aggregation_0",result="SUCCESS",server="main"} 1 135 | # HELP simple foobar 136 | # TYPE simple gauge 137 | simple{server="main"} 2 138 | `, 139 | }, 140 | { 141 | name: "Unlabeled gauge no value found in result", 142 | aggregation: &Aggregation{ 143 | Metrics: []*Metric{ 144 | { 145 | Name: "simple_gauge_value_not_found", 146 | Type: "gauge", 147 | }, 148 | }, 149 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 150 | }, 151 | docs: []interface{}{AggregationResult{}}, 152 | //error: "1 error occurred:\n\t* value not found in result set\n\n", 153 | expected: ``, 154 | }, 155 | { 156 | name: "Unlabeled gauge no value found in result but OverrideEmpty is set with EmptyValue 0", 157 | aggregation: &Aggregation{ 158 | Metrics: []*Metric{ 159 | { 160 | Name: "simple_gauge_value_not_found_overridden", 161 | Type: "gauge", 162 | Help: "overridden", 163 | OverrideEmpty: true, 164 | EmptyValue: 12, 165 | }, 166 | }, 167 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 168 | }, 169 | expected: ` 170 | # HELP simple_gauge_value_not_found_overridden overridden 171 | # TYPE simple_gauge_value_not_found_overridden gauge 172 | simple_gauge_value_not_found_overridden{server="main"} 12 173 | `, 174 | }, 175 | { 176 | name: "Unlabeled gauge value not of type float", 177 | aggregation: &Aggregation{ 178 | Metrics: []*Metric{ 179 | { 180 | Name: "simple_gauge_value_not_float", 181 | Type: "gauge", 182 | Value: "total", 183 | }, 184 | }, 185 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 186 | }, 187 | docs: []interface{}{AggregationResult{"total": "bar"}}, 188 | expected: ``, 189 | //error: "1 error occurred:\n\t* provided value taken from the aggregation result has to be a number, type string given\n\n", 190 | }, 191 | { 192 | name: "Labeled gauge labels not found in result", 193 | aggregation: &Aggregation{ 194 | Metrics: []*Metric{ 195 | { 196 | Name: "simple_gauge_label_not_found", 197 | Type: "gauge", 198 | Value: "total", 199 | Labels: []string{"foo"}, 200 | }, 201 | }, 202 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 203 | }, 204 | docs: []interface{}{AggregationResult{"total": float64(1)}}, 205 | expected: ``, 206 | //error: "1 error occurred:\n\t* required label foo not found in result set\n\n", 207 | }, 208 | { 209 | name: "Labeled gauge with existing label but not as a string", 210 | aggregation: &Aggregation{ 211 | Metrics: []*Metric{ 212 | { 213 | Name: "simple_gauge_non_string_label", 214 | Type: "gauge", 215 | Value: "total", 216 | Labels: []string{"foo"}, 217 | }, 218 | }, 219 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 220 | }, 221 | //error: "1 error occurred:\n\t* provided label value taken from the aggregation result has to be a string, type bool given\n\n", 222 | docs: []interface{}{AggregationResult{ 223 | "total": float64(1), 224 | "foo": true, 225 | }}, 226 | expected: ``, 227 | }, 228 | { 229 | name: "Labeled gauge with existing label but not as a string with ERROR counter", 230 | counter: true, 231 | aggregation: &Aggregation{ 232 | Metrics: []*Metric{ 233 | { 234 | Name: "simple_gauge_non_string_label", 235 | Type: "gauge", 236 | Value: "total", 237 | Labels: []string{"foo"}, 238 | }, 239 | }, 240 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 241 | }, 242 | //error: "1 error occurred:\n\t* provided label value taken from the aggregation result has to be a string, type bool given\n\n", 243 | docs: []interface{}{AggregationResult{ 244 | "total": float64(1), 245 | "foo": true, 246 | }}, 247 | expected: ` 248 | # HELP counter_total mongodb query stats 249 | # TYPE counter_total counter 250 | counter_total{aggregation="aggregation_0",result="ERROR",server="main"} 1 251 | `, 252 | }, 253 | { 254 | name: "Labeled gauge with labels and valid value results in a success", 255 | aggregation: &Aggregation{ 256 | Metrics: []*Metric{ 257 | { 258 | Name: "simple_gauge_label", 259 | Type: "gauge", 260 | Help: "foobar", 261 | Value: "total", 262 | Labels: []string{"foo"}, 263 | }, 264 | }, 265 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 266 | }, 267 | docs: []interface{}{AggregationResult{ 268 | "total": float64(1), 269 | "foo": "bar", 270 | }}, 271 | expected: ` 272 | # HELP simple_gauge_label foobar 273 | # TYPE simple_gauge_label gauge 274 | simple_gauge_label{foo="bar",server="main"} 1 275 | `, 276 | }, 277 | { 278 | name: "Empty result and overrideEmpty is not set results in no metric", 279 | aggregation: &Aggregation{ 280 | Metrics: []*Metric{ 281 | { 282 | Name: "no_result", 283 | Type: "gauge", 284 | Help: "foobar", 285 | Value: "total", 286 | Labels: []string{"foo"}, 287 | }, 288 | }, 289 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 290 | }, 291 | docs: []interface{}{}, 292 | expected: ``, 293 | }, 294 | { 295 | name: "Metric without a value but overrideEmpty is still created", 296 | aggregation: &Aggregation{ 297 | Metrics: []*Metric{ 298 | { 299 | Name: "simple_info_metric", 300 | Type: "gauge", 301 | Help: "foobar", 302 | OverrideEmpty: true, 303 | EmptyValue: 1, 304 | Labels: []string{"foo"}, 305 | }, 306 | }, 307 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 308 | }, 309 | docs: []interface{}{AggregationResult{ 310 | "foo": "bar", 311 | }}, 312 | expected: ` 313 | # HELP simple_info_metric foobar 314 | # TYPE simple_info_metric gauge 315 | simple_info_metric{foo="bar",server="main"} 1 316 | `, 317 | }, 318 | { 319 | name: "Export multiple metrics from the same aggregation", 320 | aggregation: &Aggregation{ 321 | Metrics: []*Metric{ 322 | { 323 | Name: "simple_gauge_label", 324 | Type: "gauge", 325 | Help: "foobar", 326 | Value: "total", 327 | Labels: []string{"foo"}, 328 | }, 329 | { 330 | Name: "simple_gauge_label_with_constant", 331 | Type: "gauge", 332 | Help: "bar", 333 | Value: "total", 334 | Labels: []string{"foo"}, 335 | ConstLabels: prometheus.Labels{"foobar": "foo"}, 336 | }, 337 | }, 338 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 339 | }, 340 | docs: []interface{}{AggregationResult{ 341 | "total": float64(1), 342 | "foo": "bar", 343 | }}, 344 | expected: ` 345 | # HELP simple_gauge_label foobar 346 | # TYPE simple_gauge_label gauge 347 | simple_gauge_label{foo="bar",server="main"} 1 348 | 349 | # HELP simple_gauge_label_with_constant bar 350 | # TYPE simple_gauge_label_with_constant gauge 351 | simple_gauge_label_with_constant{foo="bar",foobar="foo",server="main"} 1 352 | `, 353 | }, 354 | } 355 | 356 | for _, test := range tests { 357 | t.Run(test.name, func(t *testing.T) { 358 | drv := buildMockDriver(test.docs) 359 | var c *Collector 360 | var counter *prometheus.CounterVec 361 | reg := prometheus.NewRegistry() 362 | 363 | if test.counter == true { 364 | counter = prometheus.NewCounterVec( 365 | prometheus.CounterOpts{ 366 | Name: "counter_total", 367 | Help: "mongodb query stats", 368 | }, 369 | []string{"aggregation", "server", "result"}, 370 | ) 371 | 372 | c = New(WithCounter(counter)) 373 | } else { 374 | c = New() 375 | } 376 | 377 | assert.NoError(t, c.RegisterServer("main", drv)) 378 | 379 | if test.error != "" { 380 | assert.Error(t, c.RegisterAggregation(test.aggregation)) 381 | return 382 | } 383 | 384 | assert.NoError(t, reg.Register(c)) 385 | assert.NoError(t, c.RegisterAggregation(test.aggregation)) 386 | assert.NoError(t, testutil.GatherAndCompare(reg, strings.NewReader(test.expected))) 387 | }) 388 | } 389 | } 390 | 391 | func TestCachedMetric(t *testing.T) { 392 | var tests = []aggregationTest{ 393 | { 394 | name: "Metric without cache (60s) provides a different value during the next scrape", 395 | aggregation: &Aggregation{ 396 | Metrics: []*Metric{ 397 | { 398 | Name: "simple_gauge_no_cache", 399 | Type: "gauge", 400 | Value: "total", 401 | Help: "foobar", 402 | }, 403 | }, 404 | Cache: 0, 405 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 406 | }, 407 | docs: []interface{}{AggregationResult{ 408 | "total": float64(1), 409 | }}, 410 | expected: ` 411 | # HELP simple_gauge_no_cache foobar 412 | # TYPE simple_gauge_no_cache gauge 413 | simple_gauge_no_cache{server="main"} 1 414 | `, 415 | expectedCached: ` 416 | # HELP simple_gauge_no_cache foobar 417 | # TYPE simple_gauge_no_cache gauge 418 | simple_gauge_no_cache{server="main"} 2 419 | `, 420 | }, 421 | { 422 | name: "Metric with cache (60s) provides the same value during the next scrape", 423 | aggregation: &Aggregation{ 424 | Metrics: []*Metric{ 425 | { 426 | Name: "simple_gauge_cached", 427 | Type: "gauge", 428 | Value: "total", 429 | Help: "Cached for 60s", 430 | }, 431 | }, 432 | Cache: 60 * time.Second, 433 | Pipeline: "[{\"$match\":{\"foo\":\"bar\"}}]", 434 | }, 435 | docs: []interface{}{AggregationResult{ 436 | "total": float64(1), 437 | }}, 438 | expected: ` 439 | # HELP simple_gauge_cached Cached for 60s 440 | # TYPE simple_gauge_cached gauge 441 | simple_gauge_cached{server="main"} 1 442 | `, 443 | expectedCached: ` 444 | # HELP simple_gauge_cached Cached for 60s 445 | # TYPE simple_gauge_cached gauge 446 | simple_gauge_cached{server="main"} 1 447 | `, 448 | }, 449 | } 450 | 451 | for _, test := range tests { 452 | t.Run(test.name, func(t *testing.T) { 453 | drv := buildMockDriver(test.docs) 454 | c := New() 455 | 456 | assert.NoError(t, c.RegisterServer("main", drv)) 457 | assert.NoError(t, c.RegisterAggregation(test.aggregation)) 458 | assert.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(test.expected))) 459 | 460 | // Set a new value before the next scrape 461 | test.docs[0] = AggregationResult{ 462 | "total": float64(2), 463 | } 464 | assert.NoError(t, testutil.CollectAndCompare(c, strings.NewReader(test.expectedCached))) 465 | }) 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /internal/collector/logger.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | // Logger interface which is used in the collector 4 | // You may use a custom loggger implementing this interface and pass to the collector 5 | // with Collector.WithLogger(logger) 6 | type Logger interface { 7 | Debugf(msg string, keysAndValues ...interface{}) 8 | Infof(msg string, keysAndValues ...interface{}) 9 | Errorf(msg string, keysAndValues ...interface{}) 10 | Warnf(msg string, keysAndValues ...interface{}) 11 | Fatalf(msg string, keysAndValues ...interface{}) 12 | Panicf(msg string, keysAndValues ...interface{}) 13 | } 14 | 15 | type dummyLogger struct{} 16 | 17 | func (*dummyLogger) Debugf(msg string, keysAndValues ...interface{}) {} 18 | func (*dummyLogger) Infof(msg string, keysAndValues ...interface{}) {} 19 | func (*dummyLogger) Errorf(msg string, keysAndValues ...interface{}) {} 20 | func (*dummyLogger) Warnf(msg string, keysAndValues ...interface{}) {} 21 | func (*dummyLogger) Fatalf(msg string, keysAndValues ...interface{}) {} 22 | func (*dummyLogger) Panicf(msg string, keysAndValues ...interface{}) {} 23 | -------------------------------------------------------------------------------- /internal/collector/mongodb.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "context" 5 | 6 | "go.mongodb.org/mongo-driver/bson" 7 | "go.mongodb.org/mongo-driver/mongo" 8 | "go.mongodb.org/mongo-driver/mongo/options" 9 | "go.mongodb.org/mongo-driver/mongo/readpref" 10 | ) 11 | 12 | // Represents a cursor to fetch records from 13 | type Cursor interface { 14 | Next(ctx context.Context) bool 15 | Close(ctx context.Context) error 16 | Decode(val interface{}) error 17 | } 18 | 19 | // MongoDB event stream 20 | type ChangeStreamEventNamespace struct { 21 | DB string 22 | Coll string 23 | } 24 | 25 | // MongoDB event stream 26 | type ChangeStreamEvent struct { 27 | NS *ChangeStreamEventNamespace 28 | } 29 | 30 | // MongoDB aggregation result 31 | type AggregationResult map[string]interface{} 32 | 33 | // MongoDB driver abstraction 34 | type Driver interface { 35 | Connect(ctx context.Context, opts ...*options.ClientOptions) error 36 | Ping(ctx context.Context, rp *readpref.ReadPref) error 37 | Aggregate(ctx context.Context, db string, col string, pipeline bson.A) (Cursor, error) 38 | Watch(ctx context.Context, db string, col string, pipeline bson.A) (Cursor, error) 39 | } 40 | 41 | // MongoDB driver 42 | type MongoDBDriver struct { 43 | client *mongo.Client 44 | } 45 | 46 | // Connect to the server 47 | func (mdb *MongoDBDriver) Connect(ctx context.Context, opts ...*options.ClientOptions) error { 48 | client, err := mongo.Connect(ctx, opts...) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | mdb.client = client 54 | return nil 55 | } 56 | 57 | // Enforce connection to the server 58 | func (mdb *MongoDBDriver) Ping(ctx context.Context, rp *readpref.ReadPref) error { 59 | return mdb.client.Ping(ctx, rp) 60 | } 61 | 62 | // Aggregation rquery 63 | func (mdb *MongoDBDriver) Aggregate(ctx context.Context, db string, col string, pipeline bson.A) (Cursor, error) { 64 | return mdb.client.Database(db).Collection(col).Aggregate(ctx, pipeline) 65 | } 66 | 67 | // Start an eventstream 68 | func (mdb *MongoDBDriver) Watch(ctx context.Context, db string, col string, pipeline bson.A) (Cursor, error) { 69 | return mdb.client.Database(db).Collection(col).Watch(ctx, pipeline) 70 | } 71 | -------------------------------------------------------------------------------- /internal/collector/mongodb_test.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | import ( 4 | "context" 5 | 6 | "go.mongodb.org/mongo-driver/bson" 7 | "go.mongodb.org/mongo-driver/mongo/options" 8 | "go.mongodb.org/mongo-driver/mongo/readpref" 9 | ) 10 | 11 | type mockMongoDBDriver struct { 12 | ChangeStreamData *mockCursor 13 | AggregateCursor *mockCursor 14 | } 15 | 16 | type mockCursor struct { 17 | Data []interface{} 18 | cursor []interface{} 19 | Current interface{} 20 | } 21 | 22 | func (cursor *mockCursor) Decode(val interface{}) error { 23 | switch val.(type) { 24 | case *AggregationResult: 25 | *val.(*AggregationResult) = cursor.Current.(AggregationResult) 26 | case *ChangeStreamEvent: 27 | *val.(*ChangeStreamEvent) = cursor.Current.(ChangeStreamEvent) 28 | } 29 | return nil 30 | } 31 | 32 | func (cursor *mockCursor) Next(ctx context.Context) bool { 33 | if len(cursor.cursor) == 0 { 34 | return false 35 | } 36 | 37 | cursor.Current, cursor.cursor = cursor.Data[0], cursor.cursor[1:] 38 | return true 39 | } 40 | 41 | func (cursor *mockCursor) Close(ctx context.Context) error { 42 | return nil 43 | } 44 | 45 | func (mdb *mockMongoDBDriver) Connect(ctx context.Context, opts ...*options.ClientOptions) error { 46 | return nil 47 | } 48 | 49 | func (mdb *mockMongoDBDriver) Ping(ctx context.Context, rp *readpref.ReadPref) error { 50 | return nil 51 | } 52 | 53 | func (mdb *mockMongoDBDriver) Aggregate(ctx context.Context, db string, col string, pipeline bson.A) (Cursor, error) { 54 | // reset cursor 55 | mdb.AggregateCursor.cursor = mdb.AggregateCursor.Data 56 | 57 | return mdb.AggregateCursor, nil 58 | } 59 | 60 | func (mdb *mockMongoDBDriver) Watch(ctx context.Context, db string, col string, pipeline bson.A) (Cursor, error) { 61 | return mdb.AggregateCursor, nil 62 | } 63 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/raffis/mongodb-query-exporter/v5/internal/collector" 8 | ) 9 | 10 | // Config defaults 11 | const ( 12 | DefaultServerName = "main" 13 | DefaultMongoDBURI = "mongodb://localhost:27017" 14 | DefaultMetricsPath = "/metrics" 15 | DefaultBindAddr = ":9412" 16 | DefaultQueryTimeout = 10 * time.Second 17 | HealthzPath = "/healthz" 18 | DefaultLogEncoder = "json" 19 | DefaultLogLevel = "warn" 20 | ) 21 | 22 | // A configuration format to build a Collector from 23 | type Config interface { 24 | GetBindAddr() string 25 | GetMetricsPath() string 26 | Build() (*collector.Collector, error) 27 | } 28 | 29 | var Counter = prometheus.NewCounterVec( 30 | prometheus.CounterOpts{ 31 | Name: "mongodb_query_exporter_query_total", 32 | Help: "How many MongoDB queries have been processed, partitioned by metric, server and status", 33 | }, 34 | []string{"aggregation", "server", "result"}, 35 | ) 36 | -------------------------------------------------------------------------------- /internal/config/v1/config.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/raffis/mongodb-query-exporter/v5/internal/collector" 10 | "github.com/raffis/mongodb-query-exporter/v5/internal/config" 11 | "github.com/raffis/mongodb-query-exporter/v5/internal/x/zap" 12 | 13 | "go.mongodb.org/mongo-driver/mongo/options" 14 | ) 15 | 16 | // Configuration v1.0 format 17 | type Config struct { 18 | MongoDB MongoDB 19 | Bind string 20 | LogLevel string 21 | Metrics []*Metric 22 | } 23 | 24 | // MongoDB client options 25 | type MongoDB struct { 26 | URI string 27 | MaxConnections int32 28 | ConnectionTimeout time.Duration 29 | DefaultInterval int64 30 | DefaultDatabase string 31 | DefaultCollection string 32 | } 33 | 34 | // Metric defines an exported metric from a MongoDB aggregation pipeline 35 | type Metric struct { 36 | Cache int64 37 | Mode string 38 | Database string 39 | Collection string 40 | Pipeline string 41 | Name string 42 | Type string 43 | Help string 44 | Value string 45 | OverrideEmpty bool 46 | EmptyValue int64 47 | ConstLabels prometheus.Labels 48 | Labels []string 49 | } 50 | 51 | // Get address where the http server should be bound to 52 | func (conf *Config) GetBindAddr() string { 53 | return conf.Bind 54 | } 55 | 56 | // Get metrics path 57 | func (conf *Config) GetMetricsPath() string { 58 | return "/metrics" 59 | } 60 | 61 | // Build a collector from a configuration v1.0 format and return an Exprter with that collector. 62 | // Note the v1.0 config does not support multiple collectors, you may instead use the v2.0 format. 63 | func (conf *Config) Build() (*collector.Collector, error) { 64 | l, err := zap.New(zap.Config{ 65 | Encoding: "console", 66 | Level: conf.LogLevel, 67 | }) 68 | 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | if conf.Bind == "" { 74 | conf.Bind = ":9412" 75 | } 76 | 77 | l.Sugar().Infof("will listen on %s", conf.Bind) 78 | env := os.Getenv("MDBEXPORTER_SERVER_0_MONGODB_URI") 79 | 80 | if env != "" { 81 | conf.MongoDB.URI = env 82 | } 83 | 84 | if conf.MongoDB.URI == "" { 85 | conf.MongoDB.URI = "mongodb://localhost:27017" 86 | } 87 | 88 | opts := options.Client().ApplyURI(conf.MongoDB.URI) 89 | l.Sugar().Infof("use mongodb hosts %#v", opts.Hosts) 90 | l.Sugar().Debug("use mongodb connection context timout of %d", conf.MongoDB.ConnectionTimeout*time.Second) 91 | 92 | ctx, cancel := context.WithTimeout(context.Background(), conf.MongoDB.ConnectionTimeout*time.Second) 93 | defer cancel() 94 | d := &collector.MongoDBDriver{} 95 | err = d.Connect(ctx, opts) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | c := collector.New( 101 | collector.WithConfig(&collector.Config{ 102 | QueryTimeout: conf.MongoDB.ConnectionTimeout, 103 | DefaultCache: time.Duration(conf.MongoDB.DefaultInterval) * time.Second, 104 | DefaultDatabase: conf.MongoDB.DefaultDatabase, 105 | DefaultCollection: conf.MongoDB.DefaultCollection, 106 | }), 107 | collector.WithLogger(l.Sugar()), 108 | collector.WithCounter(config.Counter), 109 | ) 110 | 111 | err = c.RegisterServer("main", d) 112 | if err != nil { 113 | return c, err 114 | } 115 | 116 | if len(conf.Metrics) == 0 { 117 | l.Sugar().Warn("no metrics have been configured") 118 | } 119 | 120 | for _, metric := range conf.Metrics { 121 | err := c.RegisterAggregation(&collector.Aggregation{ 122 | Cache: time.Duration(metric.Cache) * time.Second, 123 | Mode: metric.Mode, 124 | Database: metric.Database, 125 | Collection: metric.Collection, 126 | Pipeline: metric.Pipeline, 127 | Metrics: []*collector.Metric{ 128 | { 129 | Name: metric.Name, 130 | Type: metric.Type, 131 | Help: metric.Help, 132 | Value: metric.Value, 133 | OverrideEmpty: metric.OverrideEmpty, 134 | EmptyValue: metric.EmptyValue, 135 | ConstLabels: metric.ConstLabels, 136 | Labels: metric.Labels, 137 | }, 138 | }, 139 | }) 140 | 141 | if err != nil { 142 | return c, err 143 | } 144 | } 145 | 146 | return c, nil 147 | } 148 | -------------------------------------------------------------------------------- /internal/config/v1/config_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestBuild(t *testing.T) { 9 | t.Run("Build collector", func(t *testing.T) { 10 | var conf = &Config{ 11 | LogLevel: "error", 12 | MongoDB: MongoDB{}, 13 | } 14 | _, err := conf.Build() 15 | 16 | if err != nil { 17 | t.Error(err) 18 | } 19 | }) 20 | 21 | t.Run("Changed bind address is correct", func(t *testing.T) { 22 | var conf = &Config{ 23 | LogLevel: "error", 24 | Bind: ":2222", 25 | MongoDB: MongoDB{}, 26 | } 27 | 28 | _, err := conf.Build() 29 | 30 | if err != nil { 31 | t.Error(err) 32 | } 33 | 34 | if conf.GetBindAddr() != ":2222" { 35 | t.Error("Expected bind address to be :2222") 36 | } 37 | }) 38 | 39 | t.Run("MongoDB URI is overwriteable by env", func(t *testing.T) { 40 | var conf = &Config{ 41 | LogLevel: "error", 42 | Bind: ":2222", 43 | MongoDB: MongoDB{ 44 | URI: "mongodb://foo:27017", 45 | }, 46 | } 47 | 48 | os.Setenv("MDBEXPORTER_SERVER_0_MONGODB_URI", "mongodb://bar:27017") 49 | _, err := conf.Build() 50 | if err != nil { 51 | t.Error(err) 52 | } 53 | 54 | if conf.MongoDB.URI != "mongodb://bar:27017" { 55 | t.Errorf("Expected conf.Collectors[0].MongoDB.URI to be mongodb://bar:27017 but is %s", conf.MongoDB.URI) 56 | } 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /internal/config/v2/config.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/raffis/mongodb-query-exporter/v5/internal/collector" 12 | "github.com/raffis/mongodb-query-exporter/v5/internal/config" 13 | "github.com/raffis/mongodb-query-exporter/v5/internal/x/zap" 14 | 15 | "go.mongodb.org/mongo-driver/mongo/options" 16 | ) 17 | 18 | // Configuration v2.0 format 19 | type Config struct { 20 | Bind string 21 | MetricsPath string 22 | Log zap.Config 23 | Global Global 24 | Servers []*Server 25 | Metrics []*Metric 26 | } 27 | 28 | type Global struct { 29 | QueryTimeout time.Duration 30 | MaxConnections int32 31 | DefaultCache int64 32 | DefaultMode string 33 | DefaultDatabase string 34 | DefaultCollection string 35 | } 36 | 37 | // Metric defines an exported metric from a MongoDB aggregation pipeline 38 | type Metric struct { 39 | Servers []string 40 | Cache int64 41 | Mode string 42 | Database string 43 | Collection string 44 | Pipeline string 45 | Name string 46 | Type string 47 | Help string 48 | Value string 49 | OverrideEmpty bool 50 | EmptyValue int64 51 | ConstLabels prometheus.Labels 52 | Labels []string 53 | } 54 | 55 | // MongoDB client options 56 | type Server struct { 57 | Name string 58 | URI string 59 | } 60 | 61 | // Get address where the http server should be bound to 62 | func (conf *Config) GetBindAddr() string { 63 | return conf.Bind 64 | } 65 | 66 | // Get metrics path 67 | func (conf *Config) GetMetricsPath() string { 68 | return conf.MetricsPath 69 | } 70 | 71 | // Build collectors from a configuration v2.0 format and return a collection of 72 | // all configured collectors 73 | func (conf *Config) Build() (*collector.Collector, error) { 74 | if conf.Log.Encoding == "" { 75 | conf.Log.Encoding = config.DefaultLogEncoder 76 | } 77 | 78 | if conf.Log.Level == "" { 79 | conf.Log.Level = config.DefaultLogLevel 80 | } 81 | 82 | l, err := zap.New(conf.Log) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if conf.MetricsPath == "" { 88 | conf.MetricsPath = config.DefaultMetricsPath 89 | } else if conf.MetricsPath == config.HealthzPath { 90 | return nil, fmt.Errorf("%s not allowed as metrics path", config.HealthzPath) 91 | } 92 | 93 | if conf.Bind == "" { 94 | conf.Bind = config.DefaultBindAddr 95 | } 96 | 97 | l.Sugar().Infof("will listen on %s", conf.Bind) 98 | 99 | if conf.Global.QueryTimeout == 0 { 100 | conf.Global.QueryTimeout = config.DefaultQueryTimeout 101 | } 102 | 103 | if len(conf.Servers) == 0 { 104 | conf.Servers = append(conf.Servers, &Server{ 105 | Name: config.DefaultServerName, 106 | }) 107 | } 108 | 109 | config.Counter.Reset() 110 | c := collector.New( 111 | collector.WithConfig(&collector.Config{ 112 | QueryTimeout: conf.Global.QueryTimeout, 113 | DefaultCache: time.Duration(conf.Global.DefaultCache) * time.Second, 114 | DefaultMode: conf.Global.DefaultMode, 115 | DefaultDatabase: conf.Global.DefaultDatabase, 116 | DefaultCollection: conf.Global.DefaultCollection, 117 | }), 118 | collector.WithLogger(l.Sugar()), 119 | collector.WithCounter(config.Counter), 120 | ) 121 | 122 | for id, srv := range conf.Servers { 123 | env := os.Getenv(fmt.Sprintf("MDBEXPORTER_SERVER_%d_MONGODB_URI", id)) 124 | 125 | if env != "" { 126 | srv.URI = env 127 | } 128 | 129 | if srv.URI == "" { 130 | srv.URI = config.DefaultMongoDBURI 131 | } 132 | 133 | srv.URI = os.ExpandEnv(srv.URI) 134 | opts := options.Client().ApplyURI(srv.URI) 135 | l.Sugar().Infof("use mongodb hosts %#v", opts.Hosts) 136 | 137 | var err error 138 | name := srv.Name 139 | if name == "" { 140 | name = strings.Join(opts.Hosts, ",") 141 | } 142 | 143 | d := &collector.MongoDBDriver{} 144 | err = d.Connect(context.TODO(), opts) 145 | if err != nil { 146 | panic(err) 147 | } 148 | 149 | err = c.RegisterServer(name, d) 150 | if err != nil { 151 | return c, err 152 | } 153 | } 154 | 155 | if len(conf.Metrics) == 0 { 156 | l.Sugar().Warn("no metrics have been configured") 157 | } 158 | 159 | for _, metric := range conf.Metrics { 160 | err := c.RegisterAggregation(&collector.Aggregation{ 161 | Servers: metric.Servers, 162 | Cache: time.Duration(metric.Cache) * time.Second, 163 | Mode: metric.Mode, 164 | Database: metric.Database, 165 | Collection: metric.Collection, 166 | Pipeline: metric.Pipeline, 167 | Metrics: []*collector.Metric{ 168 | { 169 | Name: metric.Name, 170 | Type: metric.Type, 171 | Help: metric.Help, 172 | Value: metric.Value, 173 | OverrideEmpty: metric.OverrideEmpty, 174 | EmptyValue: metric.EmptyValue, 175 | ConstLabels: metric.ConstLabels, 176 | Labels: metric.Labels, 177 | }, 178 | }, 179 | }) 180 | 181 | if err != nil { 182 | return c, err 183 | } 184 | } 185 | 186 | return c, nil 187 | } 188 | -------------------------------------------------------------------------------- /internal/config/v2/config_test.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/raffis/mongodb-query-exporter/v5/internal/x/zap" 8 | "github.com/tj/assert" 9 | ) 10 | 11 | func TestBuild(t *testing.T) { 12 | t.Run("Build collector", func(t *testing.T) { 13 | var conf = &Config{} 14 | _, err := conf.Build() 15 | 16 | assert.NoError(t, err) 17 | }) 18 | 19 | t.Run("Changed bind address is correct", func(t *testing.T) { 20 | var conf = &Config{ 21 | Log: zap.Config{ 22 | Encoding: "console", 23 | Level: "error", 24 | }, 25 | Bind: ":2222", 26 | } 27 | 28 | _, err := conf.Build() 29 | 30 | assert.NoError(t, err) 31 | assert.Equal(t, conf.GetBindAddr(), ":2222", "Expected bind address to be equal") 32 | }) 33 | 34 | t.Run("Server is registered with name taken from mongodb URI", func(t *testing.T) { 35 | var conf = &Config{ 36 | Log: zap.Config{ 37 | Encoding: "console", 38 | Level: "error", 39 | }, 40 | Servers: []*Server{ 41 | { 42 | URI: "mongodb://foo:27017,bar:27017", 43 | }, 44 | }, 45 | } 46 | 47 | c, err := conf.Build() 48 | 49 | assert.NoError(t, err) 50 | assert.Len(t, c.GetServers([]string{"foo:27017,bar:27017"}), 1, "Expected to found one server named foo:27017,bar:27017") 51 | }) 52 | 53 | t.Run("Default server main localhost:27017 is applied if no servers are configured", func(t *testing.T) { 54 | var conf = &Config{ 55 | Log: zap.Config{ 56 | Encoding: "console", 57 | Level: "error", 58 | }, 59 | } 60 | c, err := conf.Build() 61 | assert.NoError(t, err) 62 | assert.Len(t, c.GetServers([]string{"main"}), 1, "Expected to found one server named main") 63 | }) 64 | 65 | t.Run("Server name is changeable", func(t *testing.T) { 66 | var conf = &Config{ 67 | Log: zap.Config{ 68 | Encoding: "console", 69 | Level: "error", 70 | }, 71 | Servers: []*Server{ 72 | { 73 | Name: "foo", 74 | URI: "mongodb://foo:27017", 75 | }, 76 | }, 77 | } 78 | 79 | c, err := conf.Build() 80 | 81 | assert.NoError(t, err) 82 | assert.Len(t, c.GetServers([]string{"foo"}), 1, "Expected to found one server named foo") 83 | }) 84 | 85 | t.Run("MongoDB URI is overwriteable by env", func(t *testing.T) { 86 | var conf = &Config{ 87 | Log: zap.Config{ 88 | Encoding: "console", 89 | Level: "error", 90 | }, 91 | Servers: []*Server{ 92 | { 93 | Name: "foo", 94 | URI: "mongodb://foo:27017", 95 | }, 96 | { 97 | Name: "foo2", 98 | URI: "mongodb://foo2:27017", 99 | }, 100 | }, 101 | } 102 | 103 | os.Setenv("MDBEXPORTER_SERVER_0_MONGODB_URI", "mongodb://bar:27017") 104 | os.Setenv("MDBEXPORTER_SERVER_1_MONGODB_URI", "mongodb://bar2:27017") 105 | _, err := conf.Build() 106 | assert.NoError(t, err) 107 | assert.Equal(t, conf.Servers[0].URI, "mongodb://bar:27017", "Expected conf.Collectors[0].MongoDB.URI to be mongodb://bar:27017") 108 | assert.Equal(t, conf.Servers[1].URI, "mongodb://bar2:27017", "Expected conf.Collectors[0].MongoDB.URI to be mongodb://bar2:27017") 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /internal/config/v3/config.go: -------------------------------------------------------------------------------- 1 | package v3 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/raffis/mongodb-query-exporter/v5/internal/collector" 12 | "github.com/raffis/mongodb-query-exporter/v5/internal/config" 13 | "github.com/raffis/mongodb-query-exporter/v5/internal/x/zap" 14 | 15 | "go.mongodb.org/mongo-driver/mongo/options" 16 | ) 17 | 18 | // Configuration v3.0 format 19 | type Config struct { 20 | Bind string 21 | MetricsPath string 22 | Log zap.Config 23 | Global Global 24 | Servers []*Server 25 | Aggregations []*collector.Aggregation 26 | } 27 | 28 | // Global config 29 | type Global struct { 30 | QueryTimeout time.Duration 31 | MaxConnections int32 32 | DefaultCache time.Duration 33 | DefaultMode string 34 | DefaultDatabase string 35 | DefaultCollection string 36 | } 37 | 38 | // Aggregation defines what aggregation pipeline is executed on what servers 39 | type Aggregation struct { 40 | Servers []string 41 | Cache time.Duration 42 | Mode string 43 | Database string 44 | Collection string 45 | Pipeline string 46 | Metrics []Metric 47 | } 48 | 49 | // Metric defines how a certain value is exported from a MongoDB aggregation 50 | type Metric struct { 51 | Name string 52 | Type string 53 | Help string 54 | Value string 55 | OverrideEmpty bool 56 | EmptyValue int64 57 | ConstLabels prometheus.Labels 58 | Labels []string 59 | } 60 | 61 | // MongoDB client options 62 | type Server struct { 63 | Name string 64 | URI string 65 | } 66 | 67 | // Get address where the http server should be bound to 68 | func (conf *Config) GetBindAddr() string { 69 | return conf.Bind 70 | } 71 | 72 | // Get metrics path 73 | func (conf *Config) GetMetricsPath() string { 74 | return conf.MetricsPath 75 | } 76 | 77 | // Build collectors from a configuration v2.0 format and return a collection of 78 | // all configured collectors 79 | func (conf *Config) Build() (*collector.Collector, error) { 80 | if conf.Log.Encoding == "" { 81 | conf.Log.Encoding = config.DefaultLogEncoder 82 | } 83 | 84 | if conf.Log.Level == "" { 85 | conf.Log.Level = config.DefaultLogLevel 86 | } 87 | 88 | l, err := zap.New(conf.Log) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if conf.MetricsPath == "" { 94 | conf.MetricsPath = config.DefaultMetricsPath 95 | } else if conf.MetricsPath == config.HealthzPath { 96 | return nil, fmt.Errorf("%s not allowed as metrics path", config.HealthzPath) 97 | } 98 | 99 | if conf.Bind == "" { 100 | conf.Bind = config.DefaultBindAddr 101 | } 102 | 103 | l.Sugar().Infof("will listen on %s", conf.Bind) 104 | 105 | if conf.Global.QueryTimeout == 0 { 106 | conf.Global.QueryTimeout = config.DefaultQueryTimeout 107 | } 108 | 109 | if len(conf.Servers) == 0 { 110 | conf.Servers = append(conf.Servers, &Server{ 111 | Name: config.DefaultServerName, 112 | }) 113 | } 114 | 115 | config.Counter.Reset() 116 | c := collector.New( 117 | collector.WithConfig(&collector.Config{ 118 | QueryTimeout: conf.Global.QueryTimeout, 119 | DefaultCache: conf.Global.DefaultCache, 120 | DefaultMode: conf.Global.DefaultMode, 121 | DefaultDatabase: conf.Global.DefaultDatabase, 122 | DefaultCollection: conf.Global.DefaultCollection, 123 | }), 124 | collector.WithLogger(l.Sugar()), 125 | collector.WithCounter(config.Counter), 126 | ) 127 | 128 | for id, srv := range conf.Servers { 129 | env := os.Getenv(fmt.Sprintf("MDBEXPORTER_SERVER_%d_MONGODB_URI", id)) 130 | 131 | if env != "" { 132 | srv.URI = env 133 | } 134 | 135 | if srv.URI == "" { 136 | srv.URI = config.DefaultMongoDBURI 137 | } 138 | 139 | srv.URI = os.ExpandEnv(srv.URI) 140 | opts := options.Client().ApplyURI(srv.URI) 141 | l.Sugar().Infof("use mongodb hosts %#v", opts.Hosts) 142 | 143 | var err error 144 | name := srv.Name 145 | if name == "" { 146 | name = strings.Join(opts.Hosts, ",") 147 | } 148 | 149 | d := &collector.MongoDBDriver{} 150 | err = d.Connect(context.TODO(), opts) 151 | if err != nil { 152 | panic(err) 153 | } 154 | 155 | err = c.RegisterServer(name, d) 156 | if err != nil { 157 | return c, err 158 | } 159 | } 160 | 161 | if len(conf.Aggregations) == 0 { 162 | l.Sugar().Warn("no aggregations have been configured") 163 | } 164 | 165 | for i, aggregation := range conf.Aggregations { 166 | opts := &collector.Aggregation{ 167 | Servers: aggregation.Servers, 168 | Cache: aggregation.Cache, 169 | Mode: aggregation.Mode, 170 | Database: aggregation.Database, 171 | Collection: aggregation.Collection, 172 | Pipeline: aggregation.Pipeline, 173 | } 174 | 175 | for _, metric := range aggregation.Metrics { 176 | opts.Metrics = append(opts.Metrics, &collector.Metric{ 177 | Name: metric.Name, 178 | Type: metric.Type, 179 | Help: metric.Help, 180 | Value: metric.Value, 181 | OverrideEmpty: metric.OverrideEmpty, 182 | EmptyValue: metric.EmptyValue, 183 | ConstLabels: metric.ConstLabels, 184 | Labels: metric.Labels, 185 | }) 186 | } 187 | 188 | if len(conf.Aggregations) == 0 { 189 | l.Sugar().Warn("no metrics have been configured for aggregation_%d", i) 190 | } 191 | 192 | err := c.RegisterAggregation(aggregation) 193 | if err != nil { 194 | return c, err 195 | } 196 | } 197 | 198 | return c, nil 199 | } 200 | -------------------------------------------------------------------------------- /internal/config/v3/config_test.go: -------------------------------------------------------------------------------- 1 | package v3 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/raffis/mongodb-query-exporter/v5/internal/x/zap" 8 | "github.com/tj/assert" 9 | ) 10 | 11 | func TestBuild(t *testing.T) { 12 | t.Run("Build collector", func(t *testing.T) { 13 | var conf = &Config{} 14 | _, err := conf.Build() 15 | 16 | assert.NoError(t, err) 17 | }) 18 | 19 | t.Run("Changed bind address is correct", func(t *testing.T) { 20 | var conf = &Config{ 21 | Log: zap.Config{ 22 | Encoding: "console", 23 | Level: "error", 24 | }, 25 | Bind: ":2222", 26 | } 27 | 28 | _, err := conf.Build() 29 | 30 | assert.NoError(t, err) 31 | assert.Equal(t, conf.GetBindAddr(), ":2222", "Expected bind address to be equal") 32 | }) 33 | 34 | t.Run("Server is registered with name taken from mongodb URI", func(t *testing.T) { 35 | var conf = &Config{ 36 | Log: zap.Config{ 37 | Encoding: "console", 38 | Level: "error", 39 | }, 40 | Servers: []*Server{ 41 | { 42 | URI: "mongodb://foo:27017,bar:27017", 43 | }, 44 | }, 45 | } 46 | 47 | c, err := conf.Build() 48 | 49 | assert.NoError(t, err) 50 | assert.Len(t, c.GetServers([]string{"foo:27017,bar:27017"}), 1, "Expected to found one server named foo:27017,bar:27017") 51 | }) 52 | 53 | t.Run("Default server main localhost:27017 is applied if no servers are configured", func(t *testing.T) { 54 | var conf = &Config{ 55 | Log: zap.Config{ 56 | Encoding: "console", 57 | Level: "error", 58 | }, 59 | } 60 | c, err := conf.Build() 61 | assert.NoError(t, err) 62 | assert.Len(t, c.GetServers([]string{"main"}), 1, "Expected to found one server named main") 63 | }) 64 | 65 | t.Run("Server name is changeable", func(t *testing.T) { 66 | var conf = &Config{ 67 | Log: zap.Config{ 68 | Encoding: "console", 69 | Level: "error", 70 | }, 71 | Servers: []*Server{ 72 | { 73 | Name: "foo", 74 | URI: "mongodb://foo:27017", 75 | }, 76 | }, 77 | } 78 | 79 | c, err := conf.Build() 80 | 81 | assert.NoError(t, err) 82 | assert.Len(t, c.GetServers([]string{"foo"}), 1, "Expected to found one server named foo") 83 | }) 84 | 85 | t.Run("MongoDB URI is overwriteable by env", func(t *testing.T) { 86 | var conf = &Config{ 87 | Log: zap.Config{ 88 | Encoding: "console", 89 | Level: "error", 90 | }, 91 | Servers: []*Server{ 92 | { 93 | Name: "foo", 94 | URI: "mongodb://foo:27017", 95 | }, 96 | { 97 | Name: "foo2", 98 | URI: "mongodb://foo2:27017", 99 | }, 100 | }, 101 | } 102 | 103 | os.Setenv("MDBEXPORTER_SERVER_0_MONGODB_URI", "mongodb://bar:27017") 104 | os.Setenv("MDBEXPORTER_SERVER_1_MONGODB_URI", "mongodb://bar2:27017") 105 | _, err := conf.Build() 106 | assert.NoError(t, err) 107 | assert.Equal(t, conf.Servers[0].URI, "mongodb://bar:27017", "Expected conf.Collectors[0].MongoDB.URI to be mongodb://bar:27017") 108 | assert.Equal(t, conf.Servers[1].URI, "mongodb://bar2:27017", "Expected conf.Collectors[0].MongoDB.URI to be mongodb://bar2:27017") 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /internal/x/zap/zap.go: -------------------------------------------------------------------------------- 1 | package zap 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | type Config struct { 9 | // Encoding sets the logger's encoding. Valid values are "json" and 10 | // "console", as well as any third-party encodings registered via 11 | // RegisterEncoder. 12 | Encoding string `json:"encoding" yaml:"encoding"` 13 | // Level is the minimum enabled logging level. Note that this is a dynamic 14 | // level, so calling Config.Level.SetLevel will atomically change the log 15 | // level of all loggers descended from this config. 16 | Level string `json:"level" yaml:"level"` 17 | // Development puts the logger in development mode, which changes the 18 | // behavior of DPanicLevel and takes stacktraces more liberally. 19 | Development bool `json:"development" yaml:"development"` 20 | // DisableCaller stops annotating logs with the calling function's file 21 | // name and line number. By default, all logs are annotated. 22 | DisableCaller bool `json:"disableCaller" yaml:"disableCaller"` 23 | } 24 | 25 | // Initialize default configz 26 | func NewConfig() Config { 27 | return Config{ 28 | Level: "info", 29 | Encoding: "json", 30 | } 31 | } 32 | 33 | // Initializes zap logger with environment variable configuration LOG_LEVEL and LOG_FORMAT. 34 | func New(config Config) (*zap.Logger, error) { 35 | var c zap.Config 36 | if config.Development { 37 | c = zap.NewDevelopmentConfig() 38 | c.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 39 | } else { 40 | c = zap.NewProductionConfig() 41 | } 42 | 43 | level := zap.NewAtomicLevel() 44 | err := level.UnmarshalText([]byte(config.Level)) 45 | 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | c.DisableStacktrace = true 51 | c.Encoding = config.Encoding 52 | c.Development = config.Development 53 | c.DisableCaller = config.DisableCaller 54 | c.Level = level 55 | 56 | logger, err := c.Build() 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | defer func() { 62 | _ = logger.Sync() 63 | }() 64 | 65 | return logger, nil 66 | } 67 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "github>whitesource/merge-confidence:beta", ":semanticCommitTypeAll(chore)"], 3 | "prHourlyLimit": 50, 4 | "prConcurrentLimit": 10, 5 | "osvVulnerabilityAlerts": true, 6 | "vulnerabilityAlerts": { 7 | "labels": [ 8 | "security" 9 | ] 10 | }, 11 | "stabilityDays": 3, 12 | "packageRules": [ 13 | { 14 | "matchPaths": ["**"], 15 | "labels": ["dependencies", "{{manager}}"] 16 | }, 17 | { 18 | "semanticCommitScope": "deps-dev", 19 | "matchManagers": ["github-actions"] 20 | }, 21 | { 22 | "description": "Automerge non-major updates", 23 | "matchUpdateTypes": ["minor", "patch"], 24 | "automerge": true 25 | } 26 | ], 27 | "postUpdateOptions": [ 28 | "gomodUpdateImportPaths", 29 | "gomodTidy" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------