diff --git a/.dockerignore b/.dockerignore index 5eca8e1b80..c528ea1189 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ data/ !.build/linux-arm64/ !.build/linux-ppc64le/ !.build/linux-s390x/ +!.build/linux-riscv64/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..432caee6f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +web/api/v1/testdata/openapi_golden.yaml linguist-generated diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 7f7cec9cda..0000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,10 +0,0 @@ -/web/ui @juliusv -/web/ui/module @juliusv @nexucis -/storage/remote @cstyan @bwplotka @tomwilkie -/storage/remote/otlptranslator @aknuds1 @jesusvazquez -/discovery/kubernetes @brancz -/tsdb @jesusvazquez -/promql @roidelapluie -/cmd/promtool @dgl -/documentation/prometheus-mixin @metalmatze - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ec4eef8dae..7873822f26 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -28,6 +28,7 @@ If no, just write "NONE" in the release-notes block below. Otherwise, please describe what should be mentioned in the CHANGELOG. Use the following prefixes: [FEATURE] [ENHANCEMENT] [PERF] [BUGFIX] [SECURITY] [CHANGE] Refer to the existing CHANGELOG for inspiration: https://github.com/prometheus/prometheus/blob/main/CHANGELOG.md +A concrete example may look as follows (be sure to leave out the surrounding quotes): "[FEATURE] API: Add /api/v1/features for clients to understand which features are supported". If you need help formulating your entries, consult the reviewer(s). --> ```release-notes diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 191e07ffac..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "monthly" - - package-ecosystem: "github-actions" - directories: - - "/" - - "/scripts" - schedule: - interval: "monthly" - - package-ecosystem: "gomod" - directories: - - "/" - - "/documentation/examples/remote_storage" - - "/internal/tools" - schedule: - interval: "monthly" - groups: - k8s.io: - patterns: - - "k8s.io/*" - go.opentelemetry.io: - patterns: - - "go.opentelemetry.io/*" - open-pull-requests-limit: 20 diff --git a/.github/workflows/automerge-dependabot.yml b/.github/workflows/automerge-dependabot.yml index 616e4ee8b6..8b07f4df95 100644 --- a/.github/workflows/automerge-dependabot.yml +++ b/.github/workflows/automerge-dependabot.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # v2.4.0 + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs diff --git a/.github/workflows/buf-lint.yml b/.github/workflows/buf-lint.yml index 4e942f1f3b..b5d6ad3864 100644 --- a/.github/workflows/buf-lint.yml +++ b/.github/workflows/buf-lint.yml @@ -12,7 +12,7 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1.50.0 diff --git a/.github/workflows/buf.yml b/.github/workflows/buf.yml index add72cc89c..58a4c87b96 100644 --- a/.github/workflows/buf.yml +++ b/.github/workflows/buf.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest if: github.repository_owner == 'prometheus' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1.50.0 @@ -25,7 +25,7 @@ jobs: with: input: 'prompb' against: 'https://github.com/prometheus/prometheus.git#branch=main,ref=HEAD~1,subdir=prompb' - - uses: bufbuild/buf-push-action@a654ff18effe4641ebea4a4ce242c49800728459 # v1.1.1 + - uses: bufbuild/buf-push-action@a654ff18effe4641ebea4a4ce242c49800728459 # v1.2.0 with: input: 'prompb' buf_token: ${{ secrets.BUF_TOKEN }} diff --git a/.github/workflows/check_release_notes.yml b/.github/workflows/check_release_notes.yml index b8381aff07..cfc4264602 100644 --- a/.github/workflows/check_release_notes.yml +++ b/.github/workflows/check_release_notes.yml @@ -20,7 +20,7 @@ jobs: # Don't run it on dependabot PRs either as humans would take control in case a bump introduces a breaking change. if: (github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community') && github.event.pull_request.user.login != 'dependabot[bot]' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - env: PR_DESCRIPTION: ${{ github.event.pull_request.body }} run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed4cfbf356..2482055fa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,8 @@ name: CI on: pull_request: push: + branches: [main, 'release-*'] + tags: ['v*'] permissions: contents: read @@ -16,10 +18,10 @@ jobs: # should also be updated. image: quay.io/prometheus/golang-builder:1.25-base steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/setup_environment with: enable_npm: true @@ -34,10 +36,10 @@ jobs: container: image: quay.io/prometheus/golang-builder:1.25-base steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/setup_environment - run: go test --tags=dedupelabels ./... - run: go test --tags=slicelabels -race ./cmd/prometheus ./model/textparse ./prompb/... @@ -57,9 +59,9 @@ jobs: GOEXPERIMENT: synctest container: # The go version in this image should be N-1 wrt test_go. - image: quay.io/prometheus/golang-builder:1.24-base + image: quay.io/prometheus/golang-builder:1.25-base steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - run: make build @@ -78,10 +80,10 @@ jobs: image: quay.io/prometheus/golang-builder:1.25-base steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/setup_environment with: enable_go: false @@ -97,10 +99,10 @@ jobs: name: Go tests on Windows runs-on: windows-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: 1.25.x - run: | @@ -116,7 +118,7 @@ jobs: container: image: quay.io/prometheus/golang-builder:1.25-base steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - run: go install ./cmd/promtool/. @@ -128,6 +130,27 @@ jobs: - run: make -C documentation/prometheus-mixin - run: git diff --exit-code + test-compliance: + name: Compliance testing + runs-on: ubuntu-latest + container: + # Whenever the Go version is updated here, .promu.yml + # should also be updated. + image: quay.io/prometheus/golang-builder:1.25-base + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 + - uses: ./.github/promci/actions/setup_environment + with: + enable_npm: false + # NOTE: Those tests are based on https://github.com/prometheus/compliance and + # are executed against the ./cmd/prometheus main package. + - run: go test -skip ${SKIP_TESTS} -v --tags=compliance ./compliance/... + env: + SKIP_TESTS: "TestRemoteWriteSender/prometheus/samples/rw2/start_timestamp*" # TODO(bwplotka): PROM-60 + build: name: Build Prometheus for common architectures runs-on: ubuntu-latest @@ -143,10 +166,10 @@ jobs: matrix: thread: [ 0, 1, 2 ] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/build with: promu_opts: "-p linux/amd64 -p windows/amd64 -p linux/arm64 -p darwin/amd64 -p darwin/arm64 -p linux/386" @@ -170,10 +193,10 @@ jobs: # Whenever the Go version is updated here, .promu.yml # should also be updated. steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/build with: parallelism: 12 @@ -202,30 +225,32 @@ jobs: if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} run: exit 1 check_generated_parser: + # Checks generated parser and UI functions list. Not renaming as it is a required check. name: Check generated parser runs-on: ubuntu-latest + container: + image: quay.io/prometheus/golang-builder:1.25-base steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 + - uses: ./.github/promci/actions/setup_environment with: - cache: false - go-version: 1.25.x - - name: Run goyacc and check for diff - run: make install-goyacc check-generated-parser + enable_npm: true + - run: make install-goyacc check-generated-parser + - run: make check-generated-promql-functions golangci: name: golangci-lint runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version: 1.25.x - name: Install snmp_exporter/generator dependencies @@ -235,21 +260,27 @@ jobs: id: golangci-lint-version run: echo "version=$(make print-golangci-lint-version)" >> $GITHUB_OUTPUT - name: Lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: args: --verbose version: ${{ steps.golangci-lint-version.outputs.version }} - name: Lint with slicelabels - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: # goexperiment.synctest to ensure we don't miss files that depend on it. args: --verbose --build-tags=slicelabels,goexperiment.synctest version: ${{ steps.golangci-lint-version.outputs.version }} - name: Lint with dedupelabels - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: args: --verbose --build-tags=dedupelabels version: ${{ steps.golangci-lint-version.outputs.version }} + - name: Lint in documentation/examples/remote_storage + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + args: --verbose + working-directory: documentation/examples/remote_storage + version: ${{ steps.golangci-lint-version.outputs.version }} fuzzing: uses: ./.github/workflows/fuzzing.yml if: github.event_name == 'pull_request' @@ -265,10 +296,10 @@ jobs: needs: [test_ui, test_go, test_go_more, test_go_oldest, test_windows, golangci, codeql, build_all] if: github.event_name == 'push' && github.event.ref == 'refs/heads/main' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/publish_main with: docker_hub_login: ${{ secrets.docker_hub_login }} @@ -284,10 +315,10 @@ jobs: || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v3.')) steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/publish_release with: docker_hub_login: ${{ secrets.docker_hub_login }} @@ -301,16 +332,16 @@ jobs: needs: [test_ui, codeql] steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: prometheus/promci@443c7fc2397e946bc9f5029e313a9c3441b9b86d # v0.4.7 + - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - name: Install nodejs - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version-file: "web/ui/.nvmrc" registry-url: "https://registry.npmjs.org" - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2e2143f4c8..21539d15d4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,17 +24,17 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 diff --git a/.github/workflows/container_description.yml b/.github/workflows/container_description.yml index 7de8bb8da7..d7b879f9c7 100644 --- a/.github/workflows/container_description.yml +++ b/.github/workflows/container_description.yml @@ -18,7 +18,7 @@ jobs: if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. steps: - name: git checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set docker hub repo name @@ -42,7 +42,7 @@ jobs: if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. steps: - name: git checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set quay.io org name diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index 3d3aa82d1c..f19e0ba609 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -1,30 +1,47 @@ -name: CIFuzz +name: fuzzing on: workflow_call: permissions: contents: read jobs: - Fuzzing: + fuzzing: + name: Run Go Fuzz Tests runs-on: ubuntu-latest + strategy: + matrix: + fuzz_test: [FuzzParseMetricText, FuzzParseOpenMetric, FuzzParseMetricSelector, FuzzParseExpr] steps: - - name: Build Fuzzers - id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@cafd7a0eb8ecb4e007c56897996a9b65c49c972f # master + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - oss-fuzz-project-name: "prometheus" - dry-run: false - - name: Run Fuzzers - uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@cafd7a0eb8ecb4e007c56897996a9b65c49c972f # master - # Note: Regularly check for updates to the pinned commit hash at: - # https://github.com/google/oss-fuzz/tree/master/infra/cifuzz/actions/run_fuzzers + persist-credentials: false + - name: Install Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - oss-fuzz-project-name: "prometheus" - fuzz-seconds: 600 - dry-run: false - - name: Upload Crash + go-version: 1.25.x + - name: Run Fuzzing + run: go test -fuzz=${{ matrix.fuzz_test }}$ -fuzztime=5m ./util/fuzzing + continue-on-error: true + id: fuzz + - name: Upload Crash Artifacts uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - if: failure() && steps.build.outcome == 'success' + if: failure() with: - name: artifacts - path: ./out/artifacts + name: fuzz-artifacts-${{ matrix.fuzz_test }} + path: util/fuzzing/testdata/fuzz/${{ matrix.fuzz_test }} + fuzzing_status: + # This status check aggregates the individual matrix jobs of the fuzzing + # step into a final status. Fails if a single matrix job fails, succeeds if + # all matrix jobs succeed. + name: Fuzzing + runs-on: ubuntu-latest + needs: [fuzzing] + if: always() + steps: + - name: Successful fuzzing + if: ${{ !(contains(needs.*.result, 'failure')) && !(contains(needs.*.result, 'cancelled')) }} + run: exit 0 + - name: Failing or cancelled fuzzing + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + run: exit 1 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index e7e813e3b6..8f34aad204 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest if: github.repository_owner == 'prometheus' steps: - - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 + - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 with: process-only: 'issues' issue-inactive-days: '180' diff --git a/.github/workflows/prombench.yml b/.github/workflows/prombench.yml index 65d1d71917..5eb9becc0a 100644 --- a/.github/workflows/prombench.yml +++ b/.github/workflows/prombench.yml @@ -38,8 +38,8 @@ jobs: uses: docker://prominfra/prombench:master with: args: >- - until make all_nodes_deleted; do echo "waiting for nodepools to be deleted"; sleep 10; done; make deploy; + until make all_nodes_running; do echo "waiting for nodepools to be created"; sleep 10; done; - name: Update status to failure if: failure() run: >- @@ -73,8 +73,8 @@ jobs: uses: docker://prominfra/prombench:master with: args: >- - until make all_nodes_running; do echo "waiting for nodepools to be created"; sleep 10; done; make clean; + until make all_nodes_deleted; do echo "waiting for nodepools to be deleted"; sleep 10; done; - name: Update status to failure if: failure() run: >- @@ -108,10 +108,10 @@ jobs: uses: docker://prominfra/prombench:master with: args: >- - until make all_nodes_running; do echo "waiting for nodepools to be created"; sleep 10; done; make clean; until make all_nodes_deleted; do echo "waiting for nodepools to be deleted"; sleep 10; done; make deploy; + until make all_nodes_running; do echo "waiting for nodepools to be created"; sleep 10; done; - name: Update status to failure if: failure() run: >- diff --git a/.github/workflows/repo_sync.yml b/.github/workflows/repo_sync.yml index fea1422fdc..9752c98b51 100644 --- a/.github/workflows/repo_sync.yml +++ b/.github/workflows/repo_sync.yml @@ -14,7 +14,7 @@ jobs: container: image: quay.io/prometheus/golang-builder steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - run: ./scripts/sync_repo_files.sh diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 658e140f27..b54be91620 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -21,7 +21,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -37,7 +37,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # tag=v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # tag=v6.0.0 with: name: SARIF file path: results.sarif @@ -45,6 +45,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # tag=v4.31.2 + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 86deb94097..b29097c400 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,7 +11,7 @@ jobs: if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. runs-on: ubuntu-latest steps: - - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} # opt out of defaults to avoid marking issues as stale and closing them diff --git a/.gitignore b/.gitignore index 0d99305f69..f64f775993 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ npm_licenses.tar.bz2 /vendor /.build +/go.work.sum /**/node_modules diff --git a/.golangci.yml b/.golangci.yml index 22c89a6beb..ff37050211 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -31,6 +31,7 @@ linters: - govet - loggercheck - misspell + - modernize - nilnesserr # TODO(bwplotka): Enable once https://github.com/golangci/golangci-lint/issues/3228 is fixed. # - nolintlint @@ -38,6 +39,7 @@ linters: - predeclared - revive - sloglint + - staticcheck - testifylint - unconvert - unused @@ -101,6 +103,10 @@ linters: desc: "Use github.com/klauspost/compress instead of zlib" - pkg: "golang.org/x/exp/slices" desc: "Use 'slices' instead." + - pkg: "gopkg.in/yaml.v2" + desc: "Use go.yaml.in/yaml/v2 instead of gopkg.in/yaml.v2" + - pkg: "gopkg.in/yaml.v3" + desc: "Use go.yaml.in/yaml/v3 instead of gopkg.in/yaml.v3" errcheck: exclude-functions: # Don't flag lines such as "io.Copy(io.Discard, resp.Body)". @@ -117,6 +123,12 @@ linters: - shadow - fieldalignment enable-all: true + modernize: + disable: + # Suggest replacing omitempty with omitzero for struct fields. + # Disable this check for now since it introduces too many changes in our existing codebase. + # See https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize#hdr-Analyzer_omitzero for more details. + - omitzero perfsprint: # Optimizes even if it requires an int or uint type cast. int-conversion: true @@ -175,6 +187,11 @@ linters: - name: unused-receiver - name: var-declaration - name: var-naming + # TODO(SuperQ): See: https://github.com/prometheus/prometheus/issues/17766 + arguments: + - [] + - [] + - - skip-package-name-checks: true testifylint: disable: - float-compare diff --git a/.yamllint b/.yamllint index 8d09c375fd..b329f464fb 100644 --- a/.yamllint +++ b/.yamllint @@ -2,6 +2,7 @@ extends: default ignore: | **/node_modules + web/api/v1/testdata/openapi_*_golden.yaml rules: braces: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3976ecbf81..a1afb0af59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,87 @@ # Changelog -## main / unreleased +## 3.9.1 / 2026-01-07 -* [BUGFIX] Discovery/Consul: Fix filter parameter not being applied to health service endpoint, causing Node and Node.Meta filters to be ignored. #16087 -* [FEATURE] Templates: Add urlQueryEscape to template functions. #17403 + - [BUGFIX] Agent: fix crash shortly after startup from invalid type of object. #17802 + - [BUGFIX] Scraping: fix relabel keep/drop not working. #17807 + +## 3.9.0 / 2026-01-06 + +- [CHANGE] Native Histograms are no longer experimental! Make the `native-histogram` feature flag a no-op. Use `scrape_native_histograms` config option instead. #17528 +- [CHANGE] API: Add maximum limit of 10,000 sets of statistics to TSDB status endpoint. #17647 +- [FEATURE] API: Add /api/v1/features for clients to understand which features are supported. #17427 +- [FEATURE] Promtool: Add `start_timestamp` field for unit tests. #17636 +- [FEATURE] Promtool: Add `--format seriesjson` option to `tsdb dump` to output just series labels in JSON format. #13409 +- [FEATURE] Add `--storage.tsdb.delay-compact-file.path` flag for better interoperability with Thanos. #17435 +- [FEATURE] UI: Add an option on the query drop-down menu to duplicate that query panel. #17714 +- [ENHANCEMENT]: TSDB: add flag `--storage.tsdb.block-reload-interval` to configure TSDB Block Reload Interval. #16728 +- [ENHANCEMENT] UI: Add graph option to start the chart's Y axis at zero. #17565 +- [ENHANCEMENT] Scraping: Classic protobuf format no longer requires the unit in the metric name. #16834 +- [ENHANCEMENT] PromQL, Rules, SD, Scraping: Add native histograms to complement existing summaries. #17374 +- [ENHANCEMENT] Notifications: Add a histogram `prometheus_notifications_latency_histogram_seconds` to complement the existing summary. #16637 +- [ENHANCEMENT] Remote-write: Add custom scope support for AzureAD authentication. #17483 +- [ENHANCEMENT] SD: add a `config` label with job name for most `prometheus_sd_refresh` metrics. #17138 +- [ENHANCEMENT] TSDB: New histogram `prometheus_tsdb_sample_ooo_delta`, the distribution of out-of-order samples in seconds. Collected for all samples, accepted or not. #17477 +- [ENHANCEMENT] Remote-read: Validate histograms received via remote-read. #17561 +- [PERF] TSDB: Small optimizations to postings index. #17439 +- [PERF] Scraping: Speed up relabelling of series. #17530 +- [PERF] PromQL: Small optimisations in binary operators. #17524, #17519. +- [BUGFIX] UI: PromQL autocomplete now shows the correct type and HELP text for OpenMetrics counters whose samples end in `_total`. #17682 +- [BUGFIX] UI: Fixed codemirror-promql incorrectly showing label completion suggestions after the closing curly brace of a vector selector. #17602 +- [BUGFIX] UI: Query editor no longer suggests a duration unit if one is already present after a number. #17605 +- [BUGFIX] PromQL: Fix some "vector cannot contain metrics with the same labelset" errors when experimental delayed name removal is enabled. #17678 +- [BUGFIX] PromQL: Fix possible corruption of PromQL text if the query had an empty `ignoring()` and non-empty grouping. #17643 +- [BUGFIX] PromQL: Fix resets/changes to return empty results for anchored selectors when all samples are outside the range. #17479 +- [BUGFIX] PromQL: Check more consistently for many-to-one matching in filter binary operators. #17668 +- [BUGFIX] PromQL: Fix collision in unary negation with non-overlapping series. #17708 +- [BUGFIX] PromQL: Fix collision in label_join and label_replace with non-overlapping series. #17703 +- [BUGFIX] PromQL: Fix bug with inconsistent results for queries with OR expression when experimental delayed name removal is enabled. #17161 +- [BUGFIX] PromQL: Ensure that `rate`/`increase`/`delta` of histograms results in a gauge histogram. #17608 +- [BUGFIX] PromQL: Do not panic while iterating over invalid histograms. #17559 +- [BUGFIX] TSDB: Reject chunk files whose encoded chunk length overflows int. #17533 +- [BUGFIX] TSDB: Do not panic during resolution reduction of invalid histograms. #17561 +- [BUGFIX] Remote-write Receive: Avoid duplicate labels when experimental type-and-unit-label feature is enabled. #17546 +- [BUGFIX] OTLP Receiver: Only write metadata to disk when experimental metadata-wal-records feature is enabled. #17472 + +## 3.8.1 / 2025-12-16 + +* [BUGFIX] remote: Fix Remote Write receiver, so it does not send wrong response headers for v1 flow and cause Prometheus senders to emit false partial error log and metrics. #17683 + +## 3.8.0 / 2025-11-28 + +* [CHANGE] Remote-write: Update receiving to [2.0-rc.4 spec](https://github.com/prometheus/docs/blob/60c24e450010df38cfcb4f65df874f6f9b26dbcb/docs/specs/prw/remote_write_spec_2_0.md). "created timestamp" (CT) is now called "start timestamp" (ST). #17411 +* [CHANGE] TSDB: Native Histogram Custom Bounds with a NaN threshold are now rejected. #17287 +* [FEATURE] OAuth2: support jwt-bearer grant-type (RFC7523 3.1). #17592 +* [FEATURE] Dockerfile: Add OpenContainers spec labels to Dockerfile. #16483 +* [FEATURE] SD: Add unified AWS service discovery for ec2, lightsail and ecs services. #17406 +* [FEATURE] Native histograms are now a stable, but optional feature, use the `scrape_native_histograms` config setting. #17232 #17315 +* [FEATURE] UI: Support anchored and smoothed keyword in promql editor. #17239 +* [FEATURE] UI: Show detailed relabeling steps for each discovered target. #17337 +* [FEATURE] Alerting: Add urlQueryEscape to template functions. #17403 +* [FEATURE] Promtool: Add Remote-Write 2.0 support to `promtool push metrics` via the `--protobuf_message` flag. #17417 +* [ENHANCEMENT] Clarify the docs about handling negative native histograms. #17249 +* [ENHANCEMENT] Mixin: Add static UID to the remote-write dashboard. #17256 +* [ENHANCEMENT] PromQL: Reconcile mismatched NHCB bounds in `Add` and `Sub`. #17278 +* [ENHANCEMENT] Alerting: Add "unknown" state for alerting rules that haven't been evaluated yet. #17282 +* [ENHANCEMENT] Scrape: Allow simultaneous use of classic histogram → NHCB conversion and zero-timestamp ingestion. #17305 +* [ENHANCEMENT] UI: Add smoothed/anchored in explain. #17334 +* [ENHANCEMENT] OTLP: De-duplicate any `target_info` samples with the same timestamp for the same series. #17400 +* [ENHANCEMENT] Document `use_fips_sts_endpoint` in `sigv4` config sections. #17304 +* [ENHANCEMENT] Document Prometheus Agent. #14519 +* [PERF] PromQL: Speed up parsing of variadic functions. #17316 +* [PERF] UI: Speed up alerts/rules/... pages by not rendering collapsed content. #17485 +* [PERF] UI: Performance improvement when getting label name and values in promql editor. #17194 +* [PERF] UI: Speed up /alerts for many firing alerts via virtual scrolling. #17254 +* [BUGFIX] PromQL: Fix slice indexing bug in info function on churning series. #17199 +* [BUGFIX] API: Reduce lock contention on `/api/v1/targets`. #17306 +* [BUGFIX] PromQL: Consistent handling of gauge vs. counter histograms in aggregations. #17312 +* [BUGFIX] TSDB: Allow NHCB with -Inf as the first custom value. #17320 +* [BUGFIX] UI: Fix duplicate loading of data from the API speed up rendering of some pages. #17357 +* [BUGFIX] Old UI: Fix createExpressionLink to correctly build /graph URLs so links from Alerts/Rules work again. #17365 +* [BUGFIX] PromQL: Avoid panic when parsing malformed `info` call. #17379 +* [BUGFIX] PromQL: Include histograms when enforcing sample_limit. #17390 +* [BUGFIX] Config: Fix panic if TLS CA file is absent. #17418 +* [BUGFIX] PromQL: Fix `histogram_fraction` for classic histograms and NHCB if lower bound is in the first bucket. #17424 ## 3.7.3 / 2025-10-29 @@ -201,7 +279,7 @@ ## 3.2.1 / 2025-02-25 -* [BUGFIX] Don't send Accept` header `escape=allow-utf-8` when `metric_name_validation_scheme: legacy` is configured. #16061 +* [BUGFIX] Don't send `Accept` header `escape=allow-utf-8` when `metric_name_validation_scheme: legacy` is configured. #16061 ## 3.2.0 / 2025-02-17 @@ -212,10 +290,10 @@ * [ENHANCEMENT] scrape: Add metadata for automatic metrics to WAL for `metadata-wal-records` feature. #15837 * [ENHANCEMENT] promtool: Support linting of scrape interval, through lint option `too-long-scrape-interval`. #15719 * [ENHANCEMENT] promtool: Add --ignore-unknown-fields option. #15706 -* [ENHANCEMENT] ui: Make "hide empty rules" and hide empty rules" persistent #15807 +* [ENHANCEMENT] ui: Make "hide empty rules" and "hide empty rules" persistent #15807 * [ENHANCEMENT] web/api: Add a limit parameter to `/query` and `/query_range`. #15552 * [ENHANCEMENT] api: Add fields Node and ServerTime to `/status`. #15784 -* [PERF] Scraping: defer computing labels for dropped targets until they are needed by the UI. #15261 +* [PERF] Scraping: defer computing labels for dropped targets until they are needed by the UI. #15261 * [BUGFIX] remotewrite2: Fix invalid metadata bug for metrics without metadata. #15829 * [BUGFIX] remotewrite2: Fix the unit field propagation. #15825 * [BUGFIX] scrape: Fix WAL metadata for histograms and summaries. #15832 @@ -232,9 +310,9 @@ * [ENHANCEMENT] TSDB: Improve calculation of space used by labels. #13880 * [ENHANCEMENT] Rules: new metric rule_group_last_rule_duration_sum_seconds. #15672 * [ENHANCEMENT] Observability: Export 'go_sync_mutex_wait_total_seconds_total' metric. #15339 - * [ENHANCEMEN] Remote-Write: optionally use a DNS resolver that picks a random IP. #15329 + * [ENHANCEMENT] Remote-Write: optionally use a DNS resolver that picks a random IP. #15329 * [PERF] Optimize `l=~".+"` matcher. #15474, #15684 - * [PERF] TSDB: Cache all symbols for compaction . #15455 + * [PERF] TSDB: Cache all symbols for compaction. #15455 * [PERF] TSDB: MemPostings: keep a map of label values slices. #15426 * [PERF] Remote-Write: Remove interning hook. #15456 * [PERF] Scrape: optimize string manipulation for experimental native histograms with custom buckets. #15453 diff --git a/CODEOWNERS b/CODEOWNERS index c5b7f25349..2c5dedbffa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,10 +1,29 @@ +# +# Please keep this file in sync with the MAINTAINERS.md file! +# + # Prometheus team members are members of the "default maintainers" github team. # They are code owners by default for the whole repo. * @prometheus/default-maintainers -# Example adding a dedicated maintainer for AWS SD, and also "default -# maintainers" so that they do not need to bypass codeowners check to merge -# something. -# Example comes from -# https://github.com/prometheus/prometheus/pull/17105#issuecomment-3248209452 -# /discovery/aws/ @matt-gp @prometheus/default-maintainers +# Subsystems. +/Makefile @prometheus/default-maintainers @simonpasquier @SuperQ +/cmd/promtool @prometheus/default-maintainers @dgl +/documentation/prometheus-mixin @prometheus/default-maintainers @metalmatze +/model/histogram @prometheus/default-maintainers @beorn7 @krajorama +/web/ui @prometheus/default-maintainers @juliusv +/web/ui/module @prometheus/default-maintainers @juliusv @nexucis +/promql @prometheus/default-maintainers @roidelapluie +/storage/remote @prometheus/default-maintainers @cstyan @bwplotka @tomwilkie @alexgreenbank +/storage/remote/otlptranslator @prometheus/default-maintainers @aknuds1 @jesusvazquez @ArthurSens +/tsdb @prometheus/default-maintainers @jesusvazquez @codesome @bwplotka @krajorama + +# Service discovery. +/discovery/kubernetes @prometheus/default-maintainers @brancz +/discovery/stackit @prometheus/default-maintainers @jkroepke +/discovery/aws/ @prometheus/default-maintainers @matt-gp @sysadmind +# Pending +# https://github.com/prometheus/prometheus/pull/15212#issuecomment-3575225179 +# /discovery/aliyun @prometheus/default-maintainers @KeyOfSpectator +# https://github.com/prometheus/prometheus/pull/14108#issuecomment-2639515421 +# /discovery/nomad @prometheus/default-maintainers @jaloren @jrasell diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b1b286ccf..cfb346e4d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ Prometheus uses GitHub to manage reviews of pull requests. of inspiration. Also please see our [non-goals issue](https://github.com/prometheus/docs/issues/149) on areas that the Prometheus community doesn't plan to work on. * Relevant coding style guidelines are the [Go Code Review - Comments](https://code.google.com/p/go-wiki/wiki/CodeReviewComments) + Comments](https://go.dev/wiki/CodeReviewComments) and the _Formatting and style_ section of Peter Bourgon's [Go: Best Practices for Production Environments](https://peter.bourgon.org/go-in-production/#formatting-and-style). @@ -78,8 +78,7 @@ go get example.com/some/module/pkg@vX.Y.Z Tidy up the `go.mod` and `go.sum` files: ```bash -# The GO111MODULE variable can be omitted when the code isn't located in GOPATH. -GO111MODULE=on go mod tidy +go mod tidy ``` You have to commit the changes to `go.mod` and `go.sum` before submitting the pull request. diff --git a/Dockerfile b/Dockerfile index 071e7441e3..98712d8f9c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,8 @@ LABEL org.opencontainers.image.authors="The Prometheus Authors" \ org.opencontainers.image.source="https://github.com/prometheus/prometheus" \ org.opencontainers.image.url="https://github.com/prometheus/prometheus" \ org.opencontainers.image.documentation="https://prometheus.io/docs" \ - org.opencontainers.image.licenses="Apache License 2.0" + org.opencontainers.image.licenses="Apache License 2.0" \ + io.prometheus.image.variant="busybox" ARG ARCH="amd64" ARG OS="linux" diff --git a/Dockerfile.distroless b/Dockerfile.distroless new file mode 100644 index 0000000000..0ee184a91c --- /dev/null +++ b/Dockerfile.distroless @@ -0,0 +1,29 @@ +ARG DISTROLESS_ARCH="amd64" + +# Use DISTROLESS_ARCH for base image selection (handles armv7->arm mapping). +FROM gcr.io/distroless/static-debian13:nonroot-${DISTROLESS_ARCH} +# Base image sets USER to 65532:65532 (nonroot user). + +ARG ARCH="amd64" +ARG OS="linux" + +LABEL org.opencontainers.image.authors="The Prometheus Authors" +LABEL org.opencontainers.image.vendor="Prometheus" +LABEL org.opencontainers.image.title="Prometheus" +LABEL org.opencontainers.image.description="The Prometheus monitoring system and time series database" +LABEL org.opencontainers.image.source="https://github.com/prometheus/prometheus" +LABEL org.opencontainers.image.url="https://github.com/prometheus/prometheus" +LABEL org.opencontainers.image.documentation="https://prometheus.io/docs" +LABEL org.opencontainers.image.licenses="Apache License 2.0" +LABEL io.prometheus.image.variant="distroless" + +COPY documentation/examples/prometheus.yml /etc/prometheus/prometheus.yml +COPY LICENSE NOTICE npm_licenses.tar.bz2 / +COPY .build/${OS}-${ARCH}/prometheus /bin/prometheus +COPY .build/${OS}-${ARCH}/promtool /bin/promtool + +WORKDIR /prometheus +EXPOSE 9090 +ENTRYPOINT [ "/bin/prometheus" ] +CMD [ "--config.file=/etc/prometheus/prometheus.yml", \ + "--storage.tsdb.path=/prometheus" ] diff --git a/MAINTAINERS.md b/MAINTAINERS.md index d36f82ca61..ae61059af5 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,9 +1,12 @@ # Maintainers +## Please keep this file in sync with the CODEOWNERS file! + General maintainers: * Bryan Boreham (bjboreham@gmail.com / @bboreham) * Ayoub Mrini (ayoubmrini424@gmail.com / @machine424) * Julien Pivotto (roidelapluie@prometheus.io / @roidelapluie) +* György Krajcsovits ( / @krajorama) Maintainers for specific parts of the codebase: * `cmd` @@ -13,15 +16,13 @@ Maintainers for specific parts of the codebase: * `stackit`: Jan-Otto Kröpke ( / @jkroepke) * `documentation` * `prometheus-mixin`: Matthias Loibl ( / @metalmatze) -* `model/histogram` and other code related to native histograms: Björn Rabenstein ( / @beorn7), -George Krajcsovits ( / @krajorama) * `storage` - * `remote`: Callum Styan ( / @cstyan), Bartłomiej Płotka ( / @bwplotka), Tom Wilkie (tom.wilkie@gmail.com / @tomwilkie), Nicolás Pazos ( / @npazosmendez), Alex Greenbank ( / @alexgreenbank) + * `remote`: Callum Styan ( / @cstyan), Bartłomiej Płotka ( / @bwplotka), Tom Wilkie (tom.wilkie@gmail.com / @tomwilkie), Alex Greenbank ( / @alexgreenbank) * `otlptranslator`: Arthur Silva Sens ( / @ArthurSens), Arve Knudsen ( / @aknuds1), Jesús Vázquez ( / @jesusvazquez) * `tsdb`: Ganesh Vernekar ( / @codesome), Bartłomiej Płotka ( / @bwplotka), Jesús Vázquez ( / @jesusvazquez) * `web` * `ui`: Julius Volz ( / @juliusv) - * `module`: Augustin Husson ( @nexucis) + * `module`: Augustin Husson ( / @nexucis) * `Makefile` and related build configuration: Simon Pasquier ( / @simonpasquier), Ben Kochie ( / @SuperQ) For the sake of brevity, not all subtrees are explicitly listed. Due to the diff --git a/Makefile b/Makefile index 43020998ef..ad4b90f020 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Copyright 2018 The Prometheus Authors +# Copyright The Prometheus Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -12,7 +12,7 @@ # limitations under the License. # Needs to be defined before including Makefile.common to auto-generate targets -DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le s390x +DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le riscv64 s390x UI_PATH = web/ui UI_NODE_MODULES_PATH = $(UI_PATH)/node_modules @@ -79,6 +79,20 @@ ui-lint: # new Mantine-based UI is fully integrated and the old app can be removed. cd $(UI_PATH)/react-app && npm run lint +.PHONY: generate-promql-functions +generate-promql-functions: ui-install + @echo ">> generating PromQL function signatures" + @cd $(UI_PATH)/mantine-ui/src/promql/tools && $(GO) run ./gen_functions_list > ../functionSignatures.ts + @echo ">> generating PromQL function documentation" + @cd $(UI_PATH)/mantine-ui/src/promql/tools && $(GO) run ./gen_functions_docs $(CURDIR)/docs/querying/functions.md > ../functionDocs.tsx + @echo ">> formatting generated files" + @cd $(UI_PATH)/mantine-ui && npx prettier --write --print-width 120 src/promql/functionSignatures.ts src/promql/functionDocs.tsx + +.PHONY: check-generated-promql-functions +check-generated-promql-functions: generate-promql-functions + @echo ">> checking generated PromQL functions" + @git diff --exit-code -- $(UI_PATH)/mantine-ui/src/promql/functionSignatures.ts $(UI_PATH)/mantine-ui/src/promql/functionDocs.tsx || (echo "Generated PromQL function files are out of date. Please run 'make generate-promql-functions' and commit the changes." && false) + .PHONY: assets ifndef SKIP_UI_BUILD assets: check-node-version ui-install ui-build @@ -152,15 +166,8 @@ tarball: npm_licenses common-tarball .PHONY: docker docker: npm_licenses common-docker -plugins/plugins.go: plugins.yml plugins/generate.go - @echo ">> creating plugins list" - $(GO) generate -tags plugins ./plugins - -.PHONY: plugins -plugins: plugins/plugins.go - .PHONY: build -build: assets npm_licenses assets-compress plugins common-build +build: assets npm_licenses assets-compress common-build .PHONY: bench_tsdb bench_tsdb: $(PROMU) @@ -184,14 +191,26 @@ check-go-mod-version: @echo ">> checking go.mod version matching" @./scripts/check-go-mod-version.sh +.PHONY: update-features-testdata +update-features-testdata: + @echo ">> updating features testdata" + @$(GO) test ./cmd/prometheus -run TestFeaturesAPI -update-features + +GO_SUBMODULE_DIRS := documentation/examples/remote_storage internal/tools web/ui/mantine-ui/src/promql/tools + .PHONY: update-all-go-deps -update-all-go-deps: - @$(MAKE) update-go-deps - @echo ">> updating Go dependencies in ./documentation/examples/remote_storage/" - @cd ./documentation/examples/remote_storage/ && for m in $$($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \ +update-all-go-deps: update-go-deps + $(foreach dir,$(GO_SUBMODULE_DIRS),$(MAKE) update-go-deps-in-dir DIR=$(dir);) + @echo ">> syncing Go workspace" + @$(GO) work sync + +.PHONY: update-go-deps-in-dir +update-go-deps-in-dir: + @echo ">> updating Go dependencies in ./$(DIR)/" + @cd ./$(DIR) && for m in $$($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \ $(GO) get $$m; \ done - @cd ./documentation/examples/remote_storage/ && $(GO) mod tidy + @cd ./$(DIR) && $(GO) mod tidy .PHONY: check-node-version check-node-version: @@ -201,3 +220,8 @@ check-node-version: bump-go-version: @echo ">> bumping Go minor version" @./scripts/bump_go_version.sh + +.PHONY: generate-fuzzing-seed-corpus +generate-fuzzing-seed-corpus: + @echo ">> Generating fuzzing seed corpus" + @$(GO) generate -tags fuzzing ./util/fuzzing/corpus_gen diff --git a/Makefile.common b/Makefile.common index 143bf03fbc..d19d390d37 100644 --- a/Makefile.common +++ b/Makefile.common @@ -1,4 +1,4 @@ -# Copyright 2018 The Prometheus Authors +# Copyright The Prometheus Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -55,13 +55,13 @@ ifneq ($(shell command -v gotestsum 2> /dev/null),) endif endif -PROMU_VERSION ?= 0.17.0 +PROMU_VERSION ?= 0.18.0 PROMU_URL := https://github.com/prometheus/promu/releases/download/v$(PROMU_VERSION)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM).tar.gz SKIP_GOLANGCI_LINT := GOLANGCI_LINT := GOLANGCI_LINT_OPTS ?= -GOLANGCI_LINT_VERSION ?= v2.6.0 +GOLANGCI_LINT_VERSION ?= v2.10.1 GOLANGCI_FMT_OPTS ?= # golangci-lint only supports linux, darwin and windows platforms on i386/amd64/arm64. # windows isn't included here because of the path separator being different. @@ -82,11 +82,32 @@ endif PREFIX ?= $(shell pwd) BIN_DIR ?= $(shell pwd) DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) -DOCKERFILE_PATH ?= ./Dockerfile DOCKERBUILD_CONTEXT ?= ./ DOCKER_REPO ?= prom +# Check if deprecated DOCKERFILE_PATH is set +ifdef DOCKERFILE_PATH +$(error DOCKERFILE_PATH is deprecated. Use DOCKERFILE_VARIANTS ?= $(DOCKERFILE_PATH) in the Makefile) +endif + DOCKER_ARCHS ?= amd64 +DOCKERFILE_VARIANTS ?= Dockerfile $(wildcard Dockerfile.*) + +# Function to extract variant from Dockerfile label. +# Returns the variant name from io.prometheus.image.variant label, or "default" if not found. +define dockerfile_variant +$(strip $(or $(shell sed -n 's/.*io\.prometheus\.image\.variant="\([^"]*\)".*/\1/p' $(1)),default)) +endef + +# Check for duplicate variant names (including default for Dockerfiles without labels). +DOCKERFILE_VARIANT_NAMES := $(foreach df,$(DOCKERFILE_VARIANTS),$(call dockerfile_variant,$(df))) +DOCKERFILE_VARIANT_NAMES_SORTED := $(sort $(DOCKERFILE_VARIANT_NAMES)) +ifneq ($(words $(DOCKERFILE_VARIANT_NAMES)),$(words $(DOCKERFILE_VARIANT_NAMES_SORTED))) +$(error Duplicate variant names found. Each Dockerfile must have a unique io.prometheus.image.variant label, and only one can be without a label (default)) +endif + +# Build variant:dockerfile pairs for shell iteration. +DOCKERFILE_VARIANTS_WITH_NAMES := $(foreach df,$(DOCKERFILE_VARIANTS),$(call dockerfile_variant,$(df)):$(df)) BUILD_DOCKER_ARCHS = $(addprefix common-docker-,$(DOCKER_ARCHS)) PUBLISH_DOCKER_ARCHS = $(addprefix common-docker-publish-,$(DOCKER_ARCHS)) @@ -112,7 +133,7 @@ common-all: precheck style check_license lint yamllint unused build test .PHONY: common-style common-style: @echo ">> checking code style" - @fmtRes=$$($(GOFMT) -d $$(find . -path ./vendor -prune -o -name '*.go' -print)); \ + @fmtRes=$$($(GOFMT) -d $$(git ls-files '*.go' ':!:vendor/*' || find . -path ./vendor -prune -o -name '*.go' -print)); \ if [ -n "$${fmtRes}" ]; then \ echo "gofmt checking failed!"; echo "$${fmtRes}"; echo; \ echo "Please ensure you are using $$($(GO) version) for formatting code."; \ @@ -122,13 +143,19 @@ common-style: .PHONY: common-check_license common-check_license: @echo ">> checking license header" - @licRes=$$(for file in $$(find . -type f -iname '*.go' ! -path './vendor/*') ; do \ + @licRes=$$(for file in $$(git ls-files '*.go' ':!:vendor/*' || find . -path ./vendor -prune -o -type f -iname '*.go' -print) ; do \ awk 'NR<=3' $$file | grep -Eq "(Copyright|generated|GENERATED)" || echo $$file; \ done); \ if [ -n "$${licRes}" ]; then \ echo "license header checking failed:"; echo "$${licRes}"; \ exit 1; \ fi + @echo ">> checking for copyright years 2026 or later" + @futureYearRes=$$(git grep -E 'Copyright (202[6-9]|20[3-9][0-9])' -- '*.go' ':!:vendor/*' || true); \ + if [ -n "$${futureYearRes}" ]; then \ + echo "Files with copyright year 2026 or later found (should use 'Copyright The Prometheus Authors'):"; echo "$${futureYearRes}"; \ + exit 1; \ + fi .PHONY: common-deps common-deps: @@ -220,28 +247,110 @@ common-docker-repo-name: .PHONY: common-docker $(BUILD_DOCKER_ARCHS) common-docker: $(BUILD_DOCKER_ARCHS) $(BUILD_DOCKER_ARCHS): common-docker-%: - docker build -t "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" \ - -f $(DOCKERFILE_PATH) \ - --build-arg ARCH="$*" \ - --build-arg OS="linux" \ - $(DOCKERBUILD_CONTEXT) + @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ + dockerfile=$${variant#*:}; \ + variant_name=$${variant%%:*}; \ + distroless_arch="$*"; \ + if [ "$*" = "armv7" ]; then \ + distroless_arch="arm"; \ + fi; \ + if [ "$$dockerfile" = "Dockerfile" ]; then \ + echo "Building default variant ($$variant_name) for linux-$* using $$dockerfile"; \ + docker build -t "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" \ + -f $$dockerfile \ + --build-arg ARCH="$*" \ + --build-arg OS="linux" \ + --build-arg DISTROLESS_ARCH="$$distroless_arch" \ + $(DOCKERBUILD_CONTEXT); \ + if [ "$$variant_name" != "default" ]; then \ + echo "Tagging default variant with $$variant_name suffix"; \ + docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" \ + "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name"; \ + fi; \ + else \ + echo "Building $$variant_name variant for linux-$* using $$dockerfile"; \ + docker build -t "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" \ + -f $$dockerfile \ + --build-arg ARCH="$*" \ + --build-arg OS="linux" \ + --build-arg DISTROLESS_ARCH="$$distroless_arch" \ + $(DOCKERBUILD_CONTEXT); \ + fi; \ + done .PHONY: common-docker-publish $(PUBLISH_DOCKER_ARCHS) common-docker-publish: $(PUBLISH_DOCKER_ARCHS) $(PUBLISH_DOCKER_ARCHS): common-docker-publish-%: - docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" + @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ + dockerfile=$${variant#*:}; \ + variant_name=$${variant%%:*}; \ + if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ + echo "Pushing $$variant_name variant for linux-$*"; \ + docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name"; \ + fi; \ + if [ "$$dockerfile" = "Dockerfile" ]; then \ + echo "Pushing default variant ($$variant_name) for linux-$*"; \ + docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)"; \ + fi; \ + if [ "$(DOCKER_IMAGE_TAG)" = "latest" ]; then \ + if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ + echo "Pushing $$variant_name variant version tags for linux-$*"; \ + docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name"; \ + fi; \ + if [ "$$dockerfile" = "Dockerfile" ]; then \ + echo "Pushing default variant version tag for linux-$*"; \ + docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)"; \ + fi; \ + fi; \ + done DOCKER_MAJOR_VERSION_TAG = $(firstword $(subst ., ,$(shell cat VERSION))) .PHONY: common-docker-tag-latest $(TAG_DOCKER_ARCHS) common-docker-tag-latest: $(TAG_DOCKER_ARCHS) $(TAG_DOCKER_ARCHS): common-docker-tag-latest-%: - docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:latest" - docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)" + @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ + dockerfile=$${variant#*:}; \ + variant_name=$${variant%%:*}; \ + if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ + echo "Tagging $$variant_name variant for linux-$* as latest"; \ + docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:latest-$$variant_name"; \ + docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name"; \ + fi; \ + if [ "$$dockerfile" = "Dockerfile" ]; then \ + echo "Tagging default variant ($$variant_name) for linux-$* as latest"; \ + docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:latest"; \ + docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)"; \ + fi; \ + done .PHONY: common-docker-manifest common-docker-manifest: - DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" $(foreach ARCH,$(DOCKER_ARCHS),$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$(ARCH):$(SANITIZED_DOCKER_IMAGE_TAG)) - DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" + @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ + dockerfile=$${variant#*:}; \ + variant_name=$${variant%%:*}; \ + if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ + echo "Creating manifest for $$variant_name variant"; \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" $(foreach ARCH,$(DOCKER_ARCHS),$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$(ARCH):$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name); \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name"; \ + fi; \ + if [ "$$dockerfile" = "Dockerfile" ]; then \ + echo "Creating default variant ($$variant_name) manifest"; \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" $(foreach ARCH,$(DOCKER_ARCHS),$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$(ARCH):$(SANITIZED_DOCKER_IMAGE_TAG)); \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)"; \ + fi; \ + if [ "$(DOCKER_IMAGE_TAG)" = "latest" ]; then \ + if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ + echo "Creating manifest for $$variant_name variant version tag"; \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name" $(foreach ARCH,$(DOCKER_ARCHS),$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$(ARCH):v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name); \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name"; \ + fi; \ + if [ "$$dockerfile" = "Dockerfile" ]; then \ + echo "Creating default variant version tag manifest"; \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)" $(foreach ARCH,$(DOCKER_ARCHS),$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$(ARCH):v$(DOCKER_MAJOR_VERSION_TAG)); \ + DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)"; \ + fi; \ + fi; \ + done .PHONY: promu promu: $(PROMU) diff --git a/README.md b/README.md index 1743c5a4b8..030a827952 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ To build Prometheus from source code, You need: * Go: Version specified in [go.mod](./go.mod) or greater. * NodeJS: Version specified in [.nvmrc](./web/ui/.nvmrc) or greater. -* npm: Version 8 or greater (check with `npm --version` and [here](https://www.npmjs.com/)). +* npm: Version 10 or greater (check with `npm --version` and [here](https://www.npmjs.com/)). Start by cloning the repository: @@ -82,15 +82,15 @@ You can use the `go` tool to build and install the `prometheus` and `promtool` binaries into your `GOPATH`: ```bash -GO111MODULE=on go install github.com/prometheus/prometheus/cmd/... +go install github.com/prometheus/prometheus/cmd/... prometheus --config.file=your_config.yml ``` *However*, when using `go install` to build Prometheus, Prometheus will expect to be able to -read its web assets from local filesystem directories under `web/ui/static` and -`web/ui/templates`. In order for these assets to be found, you will have to run Prometheus -from the root of the cloned repository. Note also that these directories do not include the -React UI unless it has been built explicitly using `make assets` or `make build`. +read its web assets from local filesystem directories under `web/ui/static`. In order for +these assets to be found, you will have to run Prometheus from the root of the cloned +repository. Note also that this directory does not include the React UI unless it has been +built explicitly using `make assets` or `make build`. An example of the above configuration file can be found [here.](https://github.com/prometheus/prometheus/blob/main/documentation/examples/prometheus.yml) @@ -113,16 +113,31 @@ The Makefile provides several targets: ### Service discovery plugins -Prometheus is bundled with many service discovery plugins. -When building Prometheus from source, you can edit the [plugins.yml](./plugins.yml) -file to disable some service discoveries. The file is a yaml-formatted list of go -import path that will be built into the Prometheus binary. +Prometheus is bundled with many service discovery plugins. You can customize +which service discoveries are included in your build using Go build tags. -After you have changed the file, you -need to run `make build` again. +To exclude service discoveries when building with `make build`, add the desired +tags to the `.promu.yml` file under `build.tags.all`: -If you are using another method to compile Prometheus, `make plugins` will -generate the plugins file accordingly. +```yaml +build: + tags: + all: + - netgo + - builtinassets + - remove_all_sd # Exclude all optional SDs + - enable_kubernetes_sd # Re-enable only kubernetes +``` + +Then run `make build` as usual. Alternatively, when using `go build` directly: + +```bash +go build -tags "remove_all_sd,enable_kubernetes_sd" ./cmd/prometheus +``` + +Available build tags: +* `remove_all_sd` - Exclude all optional service discoveries (keeps file_sd, static_sd, and http_sd) +* `enable__sd` - Re-enable a specific SD when using `remove_all_sd` If you add out-of-tree plugins, which we do not endorse at the moment, additional steps might be needed to adjust the `go.mod` and `go.sum` files. As @@ -144,6 +159,15 @@ produce a fully working image when run locally. ## Using Prometheus as a Go Library +Within the Prometheus project, repositories such as [prometheus/common](https://github.com/prometheus/common) and +[prometheus/client-golang](https://github.com/prometheus/client-golang) are designed as re-usable libraries. + +The [prometheus/prometheus](https://github.com/prometheus/prometheus) repository builds a stand-alone program and is not +designed for use as a library. We are aware that people do use parts as such, +and we do not put any deliberate inconvenience in the way, but we want you to be +aware that no care has been taken to make it work well as a library. For instance, +you may encounter errors that only surface when used as a library. + ### Remote Write We are publishing our Remote Write protobuf independently at diff --git a/RELEASE.md b/RELEASE.md index 952f9f010d..5a8f8601ab 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,18 +7,20 @@ This page describes the release process and the currently planned schedule for u Release cadence of first pre-releases being cut is 6 weeks. Please see [the v2.55 RELEASE.md](https://github.com/prometheus/prometheus/blob/release-2.55/RELEASE.md) for the v2 release series schedule. -| release series | date of first pre-release (year-month-day) | release shepherd | -|----------------|--------------------------------------------|------------------------------------| -| v3.0 | 2024-11-14 | Jan Fajerski (GitHub: @jan--f) | -| v3.1 | 2024-12-17 | Bryan Boreham (GitHub: @bboreham) | -| v3.2 | 2025-01-28 | Jan Fajerski (GitHub: @jan--f) | -| v3.3 | 2025-03-11 | Ayoub Mrini (Github: @machine424) | -| v3.4 | 2025-04-29 | Jan-Otto Kröpke (Github: @jkroepke)| -| v3.5 LTS | 2025-06-03 | Bryan Boreham (GitHub: @bboreham) | -| v3.6 | 2025-08-01 | Ayoub Mrini (Github: @machine424) | -| v3.7 | 2025-09-25 | Arthur Sens and George Krajcsovits (Github: @ArthurSens and @krajorama)| -| v3.8 | 2025-11-06 | Jan Fajerski (GitHub: @jan--f) | -| v3.9 | 2025-12-18 | **volunteer welcome** | +| release series | date of first pre-release (year-month-day) | release shepherd | +|----------------|--------------------------------------------|-------------------------------------------------------------------------| +| v3.0 | 2024-11-14 | Jan Fajerski (GitHub: @jan--f) | +| v3.1 | 2024-12-17 | Bryan Boreham (GitHub: @bboreham) | +| v3.2 | 2025-01-28 | Jan Fajerski (GitHub: @jan--f) | +| v3.3 | 2025-03-11 | Ayoub Mrini (Github: @machine424) | +| v3.4 | 2025-04-29 | Jan-Otto Kröpke (Github: @jkroepke) | +| v3.5 LTS | 2025-06-03 | Bryan Boreham (GitHub: @bboreham) | +| v3.6 | 2025-08-01 | Ayoub Mrini (Github: @machine424) | +| v3.7 | 2025-09-25 | Arthur Sens and George Krajcsovits (Github: @ArthurSens and @krajorama) | +| v3.8 | 2025-11-06 | Jan Fajerski (GitHub: @jan--f) | +| v3.9 | 2025-12-18 | Bryan Boreham (GitHub: @bboreham) | +| v3.10 | 2026-02-05 | Ganesh Vernekar (Github: @codesome) | +| v3.11 | 2026-03-19 | **volunteer welcome** | If you are interested in volunteering please create a pull request against the [prometheus/prometheus](https://github.com/prometheus/prometheus) repository and propose yourself for the release series of your choice. diff --git a/VERSION b/VERSION index c1e43e6d45..6bd10744ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.7.3 +3.9.1 diff --git a/cmd/prometheus/features_test.go b/cmd/prometheus/features_test.go new file mode 100644 index 0000000000..5907c87247 --- /dev/null +++ b/cmd/prometheus/features_test.go @@ -0,0 +1,125 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/prometheus/prometheus/util/testutil" +) + +var updateFeatures = flag.Bool("update-features", false, "update features.json golden file") + +func TestFeaturesAPI(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "prometheus.yml") + require.NoError(t, os.WriteFile(configFile, []byte{}, 0o644)) + + port := testutil.RandomUnprivilegedPort(t) + prom := prometheusCommandWithLogging( + t, + configFile, + port, + fmt.Sprintf("--storage.tsdb.path=%s", tmpDir), + ) + require.NoError(t, prom.Start()) + + baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) + + // Wait for Prometheus to be ready. + require.Eventually(t, func() bool { + resp, err := http.Get(baseURL + "/-/ready") + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK + }, 10*time.Second, 100*time.Millisecond, "Prometheus didn't become ready in time") + + // Fetch features from the API. + resp, err := http.Get(baseURL + "/api/v1/features") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Parse API response. + var apiResponse struct { + Status string `json:"status"` + Data map[string]map[string]bool `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &apiResponse)) + require.Equal(t, "success", apiResponse.Status) + + goldenPath := filepath.Join("testdata", "features.json") + + // If update flag is set, write the current features to the golden file. + if *updateFeatures { + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + require.NoError(t, encoder.Encode(apiResponse.Data)) + // Ensure testdata directory exists. + require.NoError(t, os.MkdirAll(filepath.Dir(goldenPath), 0o755)) + require.NoError(t, os.WriteFile(goldenPath, buf.Bytes(), 0o644)) + t.Logf("Updated golden file: %s", goldenPath) + return + } + + // Load golden file. + goldenData, err := os.ReadFile(goldenPath) + require.NoError(t, err, "Failed to read golden file %s. Run 'make update-features-testdata' to generate it.", goldenPath) + + var expectedFeatures map[string]map[string]bool + require.NoError(t, json.Unmarshal(goldenData, &expectedFeatures)) + + // The labels implementation depends on build tags (stringlabels, slicelabels, or dedupelabels). + // We need to update the expected features to match the current build. + if prometheusFeatures, ok := expectedFeatures["prometheus"]; ok { + // Remove all label implementation features from expected. + delete(prometheusFeatures, "stringlabels") + delete(prometheusFeatures, "slicelabels") + delete(prometheusFeatures, "dedupelabels") + // Add the current implementation. + if actualPrometheus, ok := apiResponse.Data["prometheus"]; ok { + for _, impl := range []string{"stringlabels", "slicelabels", "dedupelabels"} { + if actualPrometheus[impl] { + prometheusFeatures[impl] = true + } + } + } + } + + // Compare the features data with the golden file. + require.Equal(t, expectedFeatures, apiResponse.Data, "Features mismatch. Run 'make update-features-testdata' to update the golden file.") +} diff --git a/cmd/prometheus/main.go b/cmd/prometheus/main.go index d108e4c7a2..1835fa1eff 100644 --- a/cmd/prometheus/main.go +++ b/cmd/prometheus/main.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,6 +16,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -72,11 +73,13 @@ import ( "github.com/prometheus/prometheus/scrape" "github.com/prometheus/prometheus/storage" "github.com/prometheus/prometheus/storage/remote" + "github.com/prometheus/prometheus/template" "github.com/prometheus/prometheus/tracing" "github.com/prometheus/prometheus/tsdb" "github.com/prometheus/prometheus/tsdb/agent" "github.com/prometheus/prometheus/util/compression" "github.com/prometheus/prometheus/util/documentcli" + "github.com/prometheus/prometheus/util/features" "github.com/prometheus/prometheus/util/logging" "github.com/prometheus/prometheus/util/notifications" prom_runtime "github.com/prometheus/prometheus/util/runtime" @@ -215,6 +218,8 @@ type flagConfig struct { promqlEnableDelayedNameRemoval bool + parserOpts parser.Options + promslogConfig promslog.Config } @@ -230,11 +235,14 @@ func (c *flagConfig) setFeatureListOptions(logger *slog.Logger) error { c.tsdb.EnableMemorySnapshotOnShutdown = true logger.Info("Experimental memory snapshot on shutdown enabled") case "extra-scrape-metrics": - c.scrape.ExtraMetrics = true - logger.Info("Experimental additional scrape metrics enabled") + t := true + config.DefaultConfig.GlobalConfig.ExtraScrapeMetrics = &t + config.DefaultGlobalConfig.ExtraScrapeMetrics = &t + logger.Warn("This option for --enable-feature is being phased out. It currently changes the default for the extra_scrape_metrics config setting to true, but will become a no-op in a future version. Stop using this option and set extra_scrape_metrics in the config instead.", "option", o) case "metadata-wal-records": c.scrape.AppendMetadata = true c.web.AppendMetadata = true + features.Enable(features.TSDB, "metadata_wal_records") logger.Info("Experimental metadata records in WAL enabled") case "promql-per-step-stats": c.enablePerStepStats = true @@ -249,26 +257,36 @@ func (c *flagConfig) setFeatureListOptions(logger *slog.Logger) error { c.enableConcurrentRuleEval = true logger.Info("Experimental concurrent rule evaluation enabled.") case "promql-experimental-functions": - parser.EnableExperimentalFunctions = true + c.parserOpts.EnableExperimentalFunctions = true logger.Info("Experimental PromQL functions enabled.") case "promql-duration-expr": - parser.ExperimentalDurationExpr = true + c.parserOpts.ExperimentalDurationExpr = true logger.Info("Experimental duration expression parsing enabled.") case "native-histograms": - // Change relevant global variables. Hacky, but it's hard to pass a new option or default to unmarshallers. - t := true - config.DefaultConfig.GlobalConfig.ScrapeNativeHistograms = &t - config.DefaultGlobalConfig.ScrapeNativeHistograms = &t - logger.Warn("This option for --enable-feature is being phased out. It currently changes the default for the scrape_native_histograms scrape config setting to true, but will become a no-op in v3.9+. Stop using this option and set scrape_native_histograms in the scrape config instead.", "option", o) + logger.Warn("This option for --enable-feature is a no-op. To scrape native histograms, set the scrape_native_histograms scrape config setting to true.", "option", o) case "ooo-native-histograms": logger.Warn("This option for --enable-feature is now permanently enabled and therefore a no-op.", "option", o) case "created-timestamp-zero-ingestion": - c.scrape.EnableCreatedTimestampZeroIngestion = true - c.web.CTZeroIngestionEnabled = true + // NOTE(bwplotka): Once AppendableV1 is removed, there will be only the TSDB and agent flags. + c.scrape.EnableStartTimestampZeroIngestion = true + c.web.STZeroIngestionEnabled = true + c.tsdb.EnableSTAsZeroSample = true + c.agent.EnableSTAsZeroSample = true + // Change relevant global variables. Hacky, but it's hard to pass a new option or default to unmarshallers. + // This is to widen the ST support surface. config.DefaultConfig.GlobalConfig.ScrapeProtocols = config.DefaultProtoFirstScrapeProtocols config.DefaultGlobalConfig.ScrapeProtocols = config.DefaultProtoFirstScrapeProtocols - logger.Info("Experimental created timestamp zero ingestion enabled. Changed default scrape_protocols to prefer PrometheusProto format.", "global.scrape_protocols", fmt.Sprintf("%v", config.DefaultGlobalConfig.ScrapeProtocols)) + logger.Info("Experimental start timestamp zero ingestion enabled. Changed default scrape_protocols to prefer PrometheusProto format.", "global.scrape_protocols", fmt.Sprintf("%v", config.DefaultGlobalConfig.ScrapeProtocols)) + case "st-storage": + // TODO(bwplotka): Implement ST Storage as per PROM-60 and document this hidden feature flag. + c.tsdb.EnableSTStorage = true + c.agent.EnableSTStorage = true + + // Change relevant global variables. Hacky, but it's hard to pass a new option or default to unmarshallers. This is to widen the ST support surface. + config.DefaultConfig.GlobalConfig.ScrapeProtocols = config.DefaultProtoFirstScrapeProtocols + config.DefaultGlobalConfig.ScrapeProtocols = config.DefaultProtoFirstScrapeProtocols + logger.Info("Experimental start timestamp storage enabled. Changed default scrape_protocols to prefer PrometheusProto format.", "global.scrape_protocols", fmt.Sprintf("%v", config.DefaultGlobalConfig.ScrapeProtocols)) case "delayed-compaction": c.tsdb.EnableDelayedCompaction = true logger.Info("Experimental delayed compaction is enabled.") @@ -276,8 +294,11 @@ func (c *flagConfig) setFeatureListOptions(logger *slog.Logger) error { c.promqlEnableDelayedNameRemoval = true logger.Info("Experimental PromQL delayed name removal enabled.") case "promql-extended-range-selectors": - parser.EnableExtendedRangeSelectors = true + c.parserOpts.EnableExtendedRangeSelectors = true logger.Info("Experimental PromQL extended range selectors enabled.") + case "promql-binop-fill-modifiers": + c.parserOpts.EnableBinopFillModifiers = true + logger.Info("Experimental PromQL binary operator fill modifiers enabled.") case "": continue case "old-ui": @@ -345,10 +366,14 @@ func main() { Registerer: prometheus.DefaultRegisterer, }, web: web.Options{ - Registerer: prometheus.DefaultRegisterer, - Gatherer: prometheus.DefaultGatherer, + Registerer: prometheus.DefaultRegisterer, + Gatherer: prometheus.DefaultGatherer, + FeatureRegistry: features.DefaultRegistry, }, promslogConfig: promslog.Config{}, + scrape: scrape.Options{ + FeatureRegistry: features.DefaultRegistry, + }, } a := kingpin.New(filepath.Base(os.Args[0]), "The Prometheus monitoring server").UsageWriter(os.Stdout) @@ -460,8 +485,9 @@ func main() { Default("true").Hidden().BoolVar(&cfg.tsdb.EnableOverlappingCompaction) var ( - tsdbWALCompression bool - tsdbWALCompressionType string + tsdbWALCompression bool + tsdbWALCompressionType string + tsdbDelayCompactFilePath string ) serverOnlyFlag(a, "storage.tsdb.wal-compression", "Compress the tsdb WAL. If false, the --storage.tsdb.wal-compression-type flag is ignored."). Hidden().Default("true").BoolVar(&tsdbWALCompression) @@ -478,6 +504,12 @@ func main() { serverOnlyFlag(a, "storage.tsdb.delayed-compaction.max-percent", "Sets the upper limit for the random compaction delay, specified as a percentage of the head chunk range. 100 means the compaction can be delayed by up to the entire head chunk range. Only effective when the delayed-compaction feature flag is enabled."). Default("10").Hidden().IntVar(&cfg.tsdb.CompactionDelayMaxPercent) + serverOnlyFlag(a, "storage.tsdb.delay-compact-file.path", "Path to a JSON file with uploaded TSDB blocks e.g. Thanos shipper meta file. If set TSDB will only compact 1 level blocks that are marked as uploaded in that file, improving external storage integrations e.g. with Thanos sidecar. 1+ level compactions won't be delayed."). + Default("").StringVar(&tsdbDelayCompactFilePath) + + serverOnlyFlag(a, "storage.tsdb.block-reload-interval", "Interval at which to check for new or removed blocks in storage. Users who manually backfill or drop blocks must wait up to this duration before changes become available."). + Default("1m").Hidden().SetValue(&cfg.tsdb.BlockReloadInterval) + agentOnlyFlag(a, "storage.agent.path", "Base path for metrics storage."). Default("data-agent/").StringVar(&cfg.agentStoragePath) @@ -564,7 +596,7 @@ func main() { a.Flag("scrape.discovery-reload-interval", "Interval used by scrape manager to throttle target groups updates."). Hidden().Default("5s").SetValue(&cfg.scrape.DiscoveryReloadInterval) - a.Flag("enable-feature", "Comma separated feature names to enable. Valid options: exemplar-storage, expand-external-labels, memory-snapshot-on-shutdown, promql-per-step-stats, promql-experimental-functions, extra-scrape-metrics, auto-gomaxprocs, native-histograms, created-timestamp-zero-ingestion, concurrent-rule-eval, delayed-compaction, old-ui, otlp-deltatocumulative, promql-duration-expr, use-uncached-io, promql-extended-range-selectors. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details."). + a.Flag("enable-feature", "Comma separated feature names to enable. Valid options: exemplar-storage, expand-external-labels, memory-snapshot-on-shutdown, promql-per-step-stats, promql-experimental-functions, extra-scrape-metrics, auto-gomaxprocs, created-timestamp-zero-ingestion, concurrent-rule-eval, delayed-compaction, old-ui, otlp-deltatocumulative, promql-duration-expr, use-uncached-io, promql-extended-range-selectors, promql-binop-fill-modifiers. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details."). Default("").StringsVar(&cfg.featureList) a.Flag("agent", "Run Prometheus in 'Agent mode'.").BoolVar(&agentMode) @@ -600,6 +632,8 @@ func main() { os.Exit(1) } + promqlParser := parser.NewParser(cfg.parserOpts) + if agentMode && len(serverOnlyFlags) > 0 { fmt.Fprintf(os.Stderr, "The following flag(s) can not be used in agent mode: %q", serverOnlyFlags) os.Exit(3) @@ -654,7 +688,7 @@ func main() { } // Parse rule files to verify they exist and contain valid rules. - if err := rules.ParseFiles(cfgFile.RuleFiles, cfgFile.GlobalConfig.MetricNameValidationScheme); err != nil { + if err := rules.ParseFiles(cfgFile.RuleFiles, cfgFile.GlobalConfig.MetricNameValidationScheme, promqlParser); err != nil { absPath, pathErr := filepath.Abs(cfg.configFile) if pathErr != nil { absPath = cfg.configFile @@ -669,8 +703,13 @@ func main() { } cfg.tsdb.MaxExemplars = cfgFile.StorageConfig.ExemplarsConfig.MaxExemplars } + if cfg.tsdb.BlockReloadInterval < model.Duration(1*time.Second) { + logger.Warn("The option --storage.tsdb.block-reload-interval is set to a value less than 1s. Setting it to 1s to avoid overload.") + cfg.tsdb.BlockReloadInterval = model.Duration(1 * time.Second) + } if cfgFile.StorageConfig.TSDBConfig != nil { cfg.tsdb.OutOfOrderTimeWindow = cfgFile.StorageConfig.TSDBConfig.OutOfOrderTimeWindow + cfg.tsdb.StaleSeriesCompactionThreshold = cfgFile.StorageConfig.TSDBConfig.StaleSeriesCompactionThreshold if cfgFile.StorageConfig.TSDBConfig.Retention != nil { if cfgFile.StorageConfig.TSDBConfig.Retention.Time > 0 { cfg.tsdb.RetentionDuration = cfgFile.StorageConfig.TSDBConfig.Retention.Time @@ -707,6 +746,12 @@ func main() { } } + if tsdbDelayCompactFilePath != "" { + logger.Info("Compactions will be delayed for blocks not marked as uploaded in the file tracking uploads", "path", tsdbDelayCompactFilePath) + cfg.tsdb.BlockCompactionExcludeFunc = exludeBlocksPendingUpload( + logger, tsdbDelayCompactFilePath) + } + // Now that the validity of the config is established, set the config // success metrics accordingly, although the config isn't really loaded // yet. This will happen later (including setting these metrics again), @@ -790,6 +835,12 @@ func main() { "vm_limits", prom_runtime.VMLimits(), ) + features.Set(features.Prometheus, "agent_mode", agentMode) + features.Set(features.Prometheus, "server_mode", !agentMode) + features.Set(features.Prometheus, "auto_reload_config", cfg.enableAutoReload) + features.Enable(features.Prometheus, labels.ImplementationName) + template.RegisterFeatures(features.DefaultRegistry) + var ( localStorage = &readyStorage{stats: tsdb.NewDBStats()} scraper = &readyScrapeManager{} @@ -826,13 +877,13 @@ func main() { os.Exit(1) } - discoveryManagerScrape = discovery.NewManager(ctxScrape, logger.With("component", "discovery manager scrape"), prometheus.DefaultRegisterer, sdMetrics, discovery.Name("scrape")) + discoveryManagerScrape = discovery.NewManager(ctxScrape, logger.With("component", "discovery manager scrape"), prometheus.DefaultRegisterer, sdMetrics, discovery.Name("scrape"), discovery.FeatureRegistry(features.DefaultRegistry)) if discoveryManagerScrape == nil { logger.Error("failed to create a discovery manager scrape") os.Exit(1) } - discoveryManagerNotify = discovery.NewManager(ctxNotify, logger.With("component", "discovery manager notify"), prometheus.DefaultRegisterer, sdMetrics, discovery.Name("notify")) + discoveryManagerNotify = discovery.NewManager(ctxNotify, logger.With("component", "discovery manager notify"), prometheus.DefaultRegisterer, sdMetrics, discovery.Name("notify"), discovery.FeatureRegistry(features.DefaultRegistry)) if discoveryManagerNotify == nil { logger.Error("failed to create a discovery manager notify") os.Exit(1) @@ -842,7 +893,7 @@ func main() { &cfg.scrape, logger.With("component", "scrape manager"), logging.NewJSONFileLogger, - fanoutStorage, + nil, fanoutStorage, prometheus.DefaultRegisterer, ) if err != nil { @@ -873,6 +924,8 @@ func main() { EnablePerStepStats: cfg.enablePerStepStats, EnableDelayedNameRemoval: cfg.promqlEnableDelayedNameRemoval, EnableTypeAndUnitLabels: cfg.scrape.EnableTypeAndUnitLabels, + FeatureRegistry: features.DefaultRegistry, + Parser: promqlParser, } queryEngine = promql.NewEngine(opts) @@ -895,6 +948,8 @@ func main() { DefaultRuleQueryOffset: func() time.Duration { return time.Duration(cfgFile.GlobalConfig.RuleQueryOffset) }, + FeatureRegistry: features.DefaultRegistry, + Parser: promqlParser, }) } @@ -914,6 +969,7 @@ func main() { cfg.web.LookbackDelta = time.Duration(cfg.lookbackDelta) cfg.web.IsAgent = agentMode cfg.web.AppName = modeAppName + cfg.web.Parser = promqlParser cfg.web.Version = &web.PrometheusVersion{ Version: version.Version, @@ -1135,9 +1191,11 @@ func main() { func() error { <-reloadReady.C ruleManager.Run() + logger.Info("Rule manager stopped") return nil }, func(error) { + logger.Info("Stopping rule manager manager...") ruleManager.Stop() }, ) @@ -1172,9 +1230,11 @@ func main() { func() error { <-reloadReady.C tracingManager.Run() + logger.Info("Tracing manager stopped") return nil }, func(error) { + logger.Info("Stopping tracing manager...") tracingManager.Stop() }, ) @@ -1251,6 +1311,7 @@ func main() { checksum = currentChecksum } case <-cancel: + logger.Info("Reloaders stopped") return nil } } @@ -1258,6 +1319,7 @@ func main() { func(error) { // Wait for any in-progress reloads to complete to avoid // reloading things after they have been shutdown. + logger.Info("Stopping reloaders...") cancel <- struct{}{} }, ) @@ -1331,6 +1393,9 @@ func main() { "RetentionDuration", cfg.tsdb.RetentionDuration, "WALSegmentSize", cfg.tsdb.WALSegmentSize, "WALCompressionType", cfg.tsdb.WALCompressionType, + "BlockReloadInterval", cfg.tsdb.BlockReloadInterval, + "EnableSTAsZeroSample", cfg.tsdb.EnableSTAsZeroSample, + "EnableSTStorage", cfg.tsdb.EnableSTStorage, ) startTimeMargin := int64(2 * time.Duration(cfg.tsdb.MinBlockDuration).Seconds() * 1000) @@ -1338,9 +1403,11 @@ func main() { db.SetWriteNotified(remoteStorage) close(dbOpen) <-cancel + logger.Info("TSDB stopped") return nil }, func(error) { + logger.Info("Stopping storage...") if err := fanoutStorage.Close(); err != nil { logger.Error("Error stopping storage", "err", err) } @@ -1387,15 +1454,19 @@ func main() { "MinWALTime", cfg.agent.MinWALTime, "MaxWALTime", cfg.agent.MaxWALTime, "OutOfOrderTimeWindow", cfg.agent.OutOfOrderTimeWindow, + "EnableSTAsZeroSample", cfg.agent.EnableSTAsZeroSample, + "EnableSTStorage", cfg.tsdb.EnableSTStorage, ) localStorage.Set(db, 0) db.SetWriteNotified(remoteStorage) close(dbOpen) <-cancel + logger.Info("Agent WAL storage stopped") return nil }, func(error) { + logger.Info("Stopping agent WAL storage...") if err := fanoutStorage.Close(); err != nil { logger.Error("Error stopping storage", "err", err) } @@ -1410,9 +1481,11 @@ func main() { if err := webHandler.Run(ctxWeb, listeners, *webConfig); err != nil { return fmt.Errorf("error starting web server: %w", err) } + logger.Info("Web handler stopped") return nil }, func(error) { + logger.Info("Stopping web handler...") cancelWeb() }, ) @@ -1435,6 +1508,7 @@ func main() { return nil }, func(error) { + logger.Info("Stopping notifier manager...") notifierManager.Stop() }, ) @@ -1538,7 +1612,7 @@ func reloadConfig(filename string, enableExemplarStorage bool, logger *slog.Logg logger.Error("Failed to apply configuration", "err", err) failed = true } - timingsLogger = timingsLogger.With((rl.name), time.Since(rstart)) + timingsLogger = timingsLogger.With(rl.name, time.Since(rstart)) } if failed { return fmt.Errorf("one or more errors occurred while applying the new configuration (--config.file=%q)", filename) @@ -1712,6 +1786,14 @@ func (s *readyStorage) Appender(ctx context.Context) storage.Appender { return notReadyAppender{} } +// AppenderV2 implements the Storage interface. +func (s *readyStorage) AppenderV2(ctx context.Context) storage.AppenderV2 { + if x := s.get(); x != nil { + return x.AppenderV2(ctx) + } + return notReadyAppenderV2{} +} + type notReadyAppender struct{} // SetOptions does nothing in this appender implementation. @@ -1729,7 +1811,7 @@ func (notReadyAppender) AppendHistogram(storage.SeriesRef, labels.Labels, int64, return 0, tsdb.ErrNotReady } -func (notReadyAppender) AppendHistogramCTZeroSample(storage.SeriesRef, labels.Labels, int64, int64, *histogram.Histogram, *histogram.FloatHistogram) (storage.SeriesRef, error) { +func (notReadyAppender) AppendHistogramSTZeroSample(storage.SeriesRef, labels.Labels, int64, int64, *histogram.Histogram, *histogram.FloatHistogram) (storage.SeriesRef, error) { return 0, tsdb.ErrNotReady } @@ -1737,7 +1819,7 @@ func (notReadyAppender) UpdateMetadata(storage.SeriesRef, labels.Labels, metadat return 0, tsdb.ErrNotReady } -func (notReadyAppender) AppendCTZeroSample(storage.SeriesRef, labels.Labels, int64, int64) (storage.SeriesRef, error) { +func (notReadyAppender) AppendSTZeroSample(storage.SeriesRef, labels.Labels, int64, int64) (storage.SeriesRef, error) { return 0, tsdb.ErrNotReady } @@ -1745,6 +1827,15 @@ func (notReadyAppender) Commit() error { return tsdb.ErrNotReady } func (notReadyAppender) Rollback() error { return tsdb.ErrNotReady } +type notReadyAppenderV2 struct{} + +func (notReadyAppenderV2) Append(storage.SeriesRef, labels.Labels, int64, int64, float64, *histogram.Histogram, *histogram.FloatHistogram, storage.AOptions) (storage.SeriesRef, error) { + return 0, tsdb.ErrNotReady +} +func (notReadyAppenderV2) Commit() error { return tsdb.ErrNotReady } + +func (notReadyAppenderV2) Rollback() error { return tsdb.ErrNotReady } + // Close implements the Storage interface. func (s *readyStorage) Close() error { if x := s.get(); x != nil { @@ -1887,6 +1978,11 @@ type tsdbOptions struct { CompactionDelayMaxPercent int EnableOverlappingCompaction bool UseUncachedIO bool + BlockCompactionExcludeFunc tsdb.BlockExcludeFilterFunc + BlockReloadInterval model.Duration + EnableSTAsZeroSample bool + EnableSTStorage bool + StaleSeriesCompactionThreshold float64 } func (opts tsdbOptions) ToTSDBOptions() tsdb.Options { @@ -1910,6 +2006,12 @@ func (opts tsdbOptions) ToTSDBOptions() tsdb.Options { CompactionDelayMaxPercent: opts.CompactionDelayMaxPercent, EnableOverlappingCompaction: opts.EnableOverlappingCompaction, UseUncachedIO: opts.UseUncachedIO, + BlockCompactionExcludeFunc: opts.BlockCompactionExcludeFunc, + BlockReloadInterval: time.Duration(opts.BlockReloadInterval), + FeatureRegistry: features.DefaultRegistry, + EnableSTAsZeroSample: opts.EnableSTAsZeroSample, + EnableSTStorage: opts.EnableSTStorage, + StaleSeriesCompactionThreshold: opts.StaleSeriesCompactionThreshold, } } @@ -1922,7 +2024,9 @@ type agentOptions struct { TruncateFrequency model.Duration MinWALTime, MaxWALTime model.Duration NoLockfile bool - OutOfOrderTimeWindow int64 + OutOfOrderTimeWindow int64 // TODO(bwplotka): Unused option, fix it or remove. + EnableSTAsZeroSample bool + EnableSTStorage bool } func (opts agentOptions) ToAgentOptions(outOfOrderTimeWindow int64) agent.Options { @@ -1938,6 +2042,8 @@ func (opts agentOptions) ToAgentOptions(outOfOrderTimeWindow int64) agent.Option MaxWALTime: durationToInt64Millis(time.Duration(opts.MaxWALTime)), NoLockfile: opts.NoLockfile, OutOfOrderTimeWindow: outOfOrderTimeWindow, + EnableSTAsZeroSample: opts.EnableSTAsZeroSample, + EnableSTStorage: opts.EnableSTStorage, } } @@ -1974,3 +2080,48 @@ func (p *rwProtoMsgFlagParser) Set(opt string) error { *p.msgs = append(*p.msgs, t) return nil } + +type UploadMeta struct { + Uploaded []string `json:"uploaded"` +} + +// Cache the last read UploadMeta. +var ( + tsdbDelayCompactLastMeta *UploadMeta // The content of uploadMetaPath from the last time we've opened it. + tsdbDelayCompactLastMetaTime time.Time // The timestamp at which we stored tsdbDelayCompactLastMeta last time. +) + +func exludeBlocksPendingUpload(logger *slog.Logger, uploadMetaPath string) tsdb.BlockExcludeFilterFunc { + return func(meta *tsdb.BlockMeta) bool { + if meta.Compaction.Level > 1 { + // Blocks with level > 1 are assumed to be not uploaded, thus no need to delay those. + // See `storage.tsdb.delay-compact-file.path` flag for detail. + return false + } + + // If we have cached uploadMetaPath content that was stored in the last minute the use it. + if tsdbDelayCompactLastMeta != nil && + tsdbDelayCompactLastMetaTime.After(time.Now().UTC().Add(time.Minute*-1)) { + return !slices.Contains(tsdbDelayCompactLastMeta.Uploaded, meta.ULID.String()) + } + + // We don't have anything cached or it's older than a minute. Try to open and parse the uploadMetaPath path. + data, err := os.ReadFile(uploadMetaPath) + if err != nil { + logger.Warn("cannot open TSDB upload meta file", slog.String("path", uploadMetaPath), slog.Any("err", err)) + return false + } + + var uploadMeta UploadMeta + if err = json.Unmarshal(data, &uploadMeta); err != nil { + logger.Warn("cannot parse TSDB upload meta file", slog.String("path", uploadMetaPath), slog.Any("err", err)) + return false + } + + // We have parsed the uploadMetaPath file, cache it. + tsdbDelayCompactLastMeta = &uploadMeta + tsdbDelayCompactLastMetaTime = time.Now().UTC() + + return !slices.Contains(uploadMeta.Uploaded, meta.ULID.String()) + } +} diff --git a/cmd/prometheus/main_test.go b/cmd/prometheus/main_test.go index ccc9151492..38dfd3f2da 100644 --- a/cmd/prometheus/main_test.go +++ b/cmd/prometheus/main_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -395,6 +395,7 @@ func TestTimeMetrics(t *testing.T) { } func getCurrentGaugeValuesFor(t *testing.T, reg prometheus.Gatherer, metricNames ...string) map[string]float64 { + t.Helper() f, err := reg.Gather() require.NoError(t, err) @@ -426,7 +427,7 @@ func TestAgentSuccessfulStartup(t *testing.T) { go func() { done <- prom.Wait() }() select { case err := <-done: - t.Logf("prometheus agent should be still running: %v", err) + t.Logf("prometheus agent exited early: %v", err) actualExitStatus = prom.ProcessState.ExitCode() case <-time.After(startupTime): prom.Process.Kill() @@ -571,12 +572,7 @@ func TestDocumentation(t *testing.T) { var stdout bytes.Buffer cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - var exitError *exec.ExitError - if errors.As(err, &exitError) && exitError.ExitCode() != 0 { - fmt.Println("Command failed with non-zero exit code") - } - } + require.NoError(t, cmd.Run(), "failed to generate CLI documentation via --write-documentation") generatedContent := strings.ReplaceAll(stdout.String(), filepath.Base(promPath), strings.TrimSuffix(filepath.Base(promPath), ".test")) @@ -753,7 +749,7 @@ global: configFile := filepath.Join(tmpDir, "prometheus.yml") port := testutil.RandomUnprivilegedPort(t) - os.WriteFile(configFile, []byte(tc.config), 0o777) + require.NoError(t, os.WriteFile(configFile, []byte(tc.config), 0o777)) prom := prometheusCommandWithLogging( t, configFile, @@ -801,7 +797,7 @@ global: newConfig := ` runtime: gogc: 99` - os.WriteFile(configFile, []byte(newConfig), 0o777) + require.NoError(t, os.WriteFile(configFile, []byte(newConfig), 0o777)) reloadPrometheusConfig(t, reloadURL) ensureGOGCValue(99.0) }) @@ -834,7 +830,7 @@ scrape_configs: static_configs: - targets: ['localhost:%d'] `, port, port) - os.WriteFile(configFile, []byte(config), 0o777) + require.NoError(t, os.WriteFile(configFile, []byte(config), 0o777)) prom := prometheusCommandWithLogging( t, @@ -968,7 +964,18 @@ remote_write: // TestRemoteWrite_ReshardingWithoutDeadlock ensures that resharding (scaling up) doesn't block when the shards are full. // See: https://github.com/prometheus/prometheus/issues/17384. +// +// The following shows key resharding metrics before and after the fix. +// In v3.7.0, the deadlock prevented the resharding logic from observing the true incoming data rate. +// +// | Metric | v3.7.0 | after the fix | +// |---------------------|---------------|---------------------| +// | dataInRate | 0.6 | 307.2 | +// | dataPendingRate | 0.2 | 306.8 | +// | dataPending | 0 | 1228.8 | +// | desiredShards | 0.6 | 369.2 |. func TestRemoteWrite_ReshardingWithoutDeadlock(t *testing.T) { + t.Skip("flaky test, see https://github.com/prometheus/prometheus/issues/17489") t.Parallel() tmpDir := t.TempDir() @@ -983,7 +990,8 @@ func TestRemoteWrite_ReshardingWithoutDeadlock(t *testing.T) { config := fmt.Sprintf(` global: - scrape_interval: 100ms + # Using a smaller interval may cause the scrape to time out. + scrape_interval: 1s scrape_configs: - job_name: 'self' static_configs: @@ -994,6 +1002,8 @@ remote_write: queue_config: # Speed up the queue being full. capacity: 1 + # Helps keep the “time to send one sample” low so it doesn’t influence the resharding logic. + max_samples_per_send: 1 `, port, server.URL) require.NoError(t, os.WriteFile(configFile, []byte(config), 0o777)) @@ -1002,36 +1012,52 @@ remote_write: configFile, port, fmt.Sprintf("--storage.tsdb.path=%s", tmpDir), + "--log.level=debug", ) require.NoError(t, prom.Start()) - var checkInitialDesiredShardsOnce sync.Once - require.Eventually(t, func() bool { + const desiredShardsMetric = "prometheus_remote_storage_shards_desired" + getMetrics := func() ([]byte, error) { r, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", port)) if err != nil { - return false + return nil, err } defer r.Body.Close() if r.StatusCode != http.StatusOK { - return false + return nil, fmt.Errorf("unexpected status code: %d", r.StatusCode) } metrics, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + return metrics, nil + } + + // Ensure the initial desired shards is 1. + require.Eventually(t, func() bool { + metrics, err := getMetrics() if err != nil { return false } + initialDesiredShards, err := getMetricValue(t, bytes.NewReader(metrics), model.MetricTypeGauge, desiredShardsMetric) + if err != nil { + return false + } + return initialDesiredShards == 1.0 + }, 10*time.Second, 100*time.Millisecond) - checkInitialDesiredShardsOnce.Do(func() { - s, err := getMetricValue(t, bytes.NewReader(metrics), model.MetricTypeGauge, "prometheus_remote_storage_shards_desired") - require.NoError(t, err) - require.Equal(t, 1.0, s) - }) - - desiredShards, err := getMetricValue(t, bytes.NewReader(metrics), model.MetricTypeGauge, "prometheus_remote_storage_shards_desired") - if err != nil || desiredShards <= 1 { + // Ensure scaling up is triggered after some time. + require.Eventually(t, func() bool { + metrics, err := getMetrics() + if err != nil { + return false + } + desiredShards, err := getMetricValue(t, bytes.NewReader(metrics), model.MetricTypeGauge, desiredShardsMetric) + if err != nil || desiredShards <= 1.0 { return false } return true // 3*shardUpdateDuration to allow for the resharding logic to run. - }, 30*time.Second, 1*time.Second) + }, 30*time.Second, time.Second) } diff --git a/cmd/prometheus/main_unix_test.go b/cmd/prometheus/main_unix_test.go index 66bfe9b60a..ea130b3bf9 100644 --- a/cmd/prometheus/main_unix_test.go +++ b/cmd/prometheus/main_unix_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/prometheus/query_log_test.go b/cmd/prometheus/query_log_test.go index 645ac31145..e410f836a9 100644 --- a/cmd/prometheus/query_log_test.go +++ b/cmd/prometheus/query_log_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -334,7 +334,8 @@ func (p *queryLogTest) run(t *testing.T) { p.query(t) - ql := readQueryLog(t, queryLogFile.Name()) + // Wait for query log entry to be written (avoid race with file I/O). + ql := waitForQueryLog(t, queryLogFile.Name(), 1) qc := len(ql) if p.exactQueryCount() { require.Equal(t, 1, qc) @@ -361,7 +362,8 @@ func (p *queryLogTest) run(t *testing.T) { p.query(t) qc++ - ql = readQueryLog(t, queryLogFile.Name()) + // Wait for query log entry to be written (avoid race with file I/O). + ql = waitForQueryLog(t, queryLogFile.Name(), qc) if p.exactQueryCount() { require.Len(t, ql, qc) } else { @@ -392,7 +394,8 @@ func (p *queryLogTest) run(t *testing.T) { qc++ - ql = readQueryLog(t, newFile.Name()) + // Wait for query log entry to be written (avoid race with file I/O). + ql = waitForQueryLog(t, newFile.Name(), qc) if p.exactQueryCount() { require.Len(t, ql, qc) } else { @@ -404,7 +407,8 @@ func (p *queryLogTest) run(t *testing.T) { p.query(t) - ql = readQueryLog(t, queryLogFile.Name()) + // Wait for query log entry to be written (avoid race with file I/O). + ql = waitForQueryLog(t, queryLogFile.Name(), 1) qc = len(ql) if p.exactQueryCount() { require.Equal(t, 1, qc) @@ -446,6 +450,18 @@ func readQueryLog(t *testing.T, path string) []queryLogLine { return ql } +// waitForQueryLog waits for the query log to contain at least minEntries entries, +// polling at regular intervals until the timeout is reached. +func waitForQueryLog(t *testing.T, path string, minEntries int) []queryLogLine { + t.Helper() + var ql []queryLogLine + require.Eventually(t, func() bool { + ql = readQueryLog(t, path) + return len(ql) >= minEntries + }, 5*time.Second, 100*time.Millisecond, "timed out waiting for query log to have at least %d entries, got %d", minEntries, len(ql)) + return ql +} + func TestQueryLog(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") diff --git a/cmd/prometheus/reload_test.go b/cmd/prometheus/reload_test.go index 6feb2bf3a5..bbe108c9a6 100644 --- a/cmd/prometheus/reload_test.go +++ b/cmd/prometheus/reload_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/prometheus/scrape_failure_log_test.go b/cmd/prometheus/scrape_failure_log_test.go index f35cb7bee6..c3f459f601 100644 --- a/cmd/prometheus/scrape_failure_log_test.go +++ b/cmd/prometheus/scrape_failure_log_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/prometheus/testdata/features.json b/cmd/prometheus/testdata/features.json new file mode 100644 index 0000000000..ce7dbbaebe --- /dev/null +++ b/cmd/prometheus/testdata/features.json @@ -0,0 +1,257 @@ +{ + "api": { + "admin": false, + "exclude_alerts": true, + "label_values_match": true, + "lifecycle": false, + "openapi_3.1": true, + "openapi_3.2": true, + "otlp_write_receiver": false, + "query_stats": true, + "query_warnings": true, + "remote_write_receiver": false, + "time_range_labels": true, + "time_range_series": true + }, + "otlp_receiver": { + "delta_conversion": false, + "native_delta_ingestion": false + }, + "prometheus": { + "agent_mode": false, + "auto_reload_config": false, + "server_mode": true, + "stringlabels": true + }, + "promql": { + "anchored": false, + "at_modifier": true, + "bool": true, + "by": true, + "delayed_name_removal": false, + "duration_expr": false, + "fill": false, + "fill_left": false, + "fill_right": false, + "group_left": true, + "group_right": true, + "ignoring": true, + "negative_offset": true, + "offset": true, + "on": true, + "per_query_lookback_delta": true, + "per_step_stats": false, + "smoothed": false, + "subqueries": true, + "type_and_unit_labels": false, + "without": true + }, + "promql_functions": { + "abs": true, + "absent": true, + "absent_over_time": true, + "acos": true, + "acosh": true, + "asin": true, + "asinh": true, + "atan": true, + "atanh": true, + "avg_over_time": true, + "ceil": true, + "changes": true, + "clamp": true, + "clamp_max": true, + "clamp_min": true, + "cos": true, + "cosh": true, + "count_over_time": true, + "day_of_month": true, + "day_of_week": true, + "day_of_year": true, + "days_in_month": true, + "deg": true, + "delta": true, + "deriv": true, + "double_exponential_smoothing": false, + "exp": true, + "first_over_time": false, + "floor": true, + "histogram_avg": true, + "histogram_count": true, + "histogram_fraction": true, + "histogram_quantile": true, + "histogram_quantiles": false, + "histogram_stddev": true, + "histogram_stdvar": true, + "histogram_sum": true, + "hour": true, + "idelta": true, + "increase": true, + "info": false, + "irate": true, + "label_join": true, + "label_replace": true, + "last_over_time": true, + "ln": true, + "log10": true, + "log2": true, + "mad_over_time": false, + "max_over_time": true, + "min_over_time": true, + "minute": true, + "month": true, + "pi": true, + "predict_linear": true, + "present_over_time": true, + "quantile_over_time": true, + "rad": true, + "rate": true, + "resets": true, + "round": true, + "scalar": true, + "sgn": true, + "sin": true, + "sinh": true, + "sort": true, + "sort_by_label": false, + "sort_by_label_desc": false, + "sort_desc": true, + "sqrt": true, + "stddev_over_time": true, + "stdvar_over_time": true, + "sum_over_time": true, + "tan": true, + "tanh": true, + "time": true, + "timestamp": true, + "ts_of_first_over_time": false, + "ts_of_last_over_time": false, + "ts_of_max_over_time": false, + "ts_of_min_over_time": false, + "vector": true, + "year": true + }, + "promql_operators": { + "!=": true, + "!~": true, + "%": true, + "*": true, + "+": true, + "-": true, + "/": true, + "<": true, + "<=": true, + "==": true, + "=~": true, + ">": true, + ">=": true, + "@": true, + "^": true, + "and": true, + "atan2": true, + "avg": true, + "bottomk": true, + "count": true, + "count_values": true, + "group": true, + "limit_ratio": false, + "limitk": false, + "max": true, + "min": true, + "or": true, + "quantile": true, + "stddev": true, + "stdvar": true, + "sum": true, + "topk": true, + "unless": true + }, + "rules": { + "concurrent_rule_eval": false, + "keep_firing_for": true, + "query_offset": true + }, + "scrape": { + "extra_scrape_metrics": true, + "start_timestamp_zero_ingestion": false, + "type_and_unit_labels": false + }, + "service_discovery_providers": { + "aws": true, + "azure": true, + "consul": true, + "digitalocean": true, + "dns": true, + "docker": true, + "dockerswarm": true, + "ec2": true, + "ecs": true, + "elasticache": true, + "eureka": true, + "file": true, + "gce": true, + "hetzner": true, + "http": true, + "ionos": true, + "kubernetes": true, + "kuma": true, + "lightsail": true, + "linode": true, + "marathon": true, + "msk": true, + "nerve": true, + "nomad": true, + "openstack": true, + "ovhcloud": true, + "puppetdb": true, + "scaleway": true, + "serverset": true, + "stackit": true, + "static": true, + "triton": true, + "uyuni": true, + "vultr": true + }, + "templating_functions": { + "args": true, + "externalURL": true, + "first": true, + "graphLink": true, + "humanize": true, + "humanize1024": true, + "humanizeDuration": true, + "humanizePercentage": true, + "humanizeTimestamp": true, + "label": true, + "match": true, + "now": true, + "parseDuration": true, + "pathPrefix": true, + "query": true, + "reReplaceAll": true, + "safeHtml": true, + "sortByLabel": true, + "stripDomain": true, + "stripPort": true, + "strvalue": true, + "tableLink": true, + "title": true, + "toDuration": true, + "toLower": true, + "toTime": true, + "toUpper": true, + "urlQueryEscape": true, + "value": true + }, + "tsdb": { + "delayed_compaction": false, + "exemplar_storage": false, + "isolation": true, + "native_histograms": true, + "use_uncached_io": false + }, + "ui": { + "ui_v2": false, + "ui_v3": true + } +} diff --git a/cmd/prometheus/upload_test.go b/cmd/prometheus/upload_test.go new file mode 100644 index 0000000000..97a98351a7 --- /dev/null +++ b/cmd/prometheus/upload_test.go @@ -0,0 +1,144 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "os" + "path" + "testing" + "time" + + "github.com/oklog/ulid/v2" + "github.com/prometheus/common/promslog" + "github.com/stretchr/testify/require" + + "github.com/prometheus/prometheus/tsdb" +) + +func TestBlockExcludeFilter(t *testing.T) { + for _, test := range []struct { + summary string // Description of the test case. + uploaded []ulid.ULID // List of blocks marked as uploaded inside the shipper file. + setupFn func(string) // Optional function to run before the test, takes the path to the shipper file. + meta tsdb.BlockMeta // Meta of the block we're checking. + isExcluded bool // What do we expect to be returned. + }{ + { + summary: "missing file", + setupFn: func(path string) { + // Delete shipper file to test error handling. + os.Remove(path) + }, + meta: tsdb.BlockMeta{ULID: ulid.MustNew(1, nil)}, + isExcluded: false, + }, + { + summary: "corrupt file", + setupFn: func(path string) { + // Overwrite the shipper file content with invalid JSON. + os.WriteFile(path, []byte("{["), 0o644) + }, + meta: tsdb.BlockMeta{ULID: ulid.MustNew(1, nil)}, + isExcluded: false, + }, + { + summary: "empty uploaded list", + uploaded: []ulid.ULID{}, + meta: tsdb.BlockMeta{ULID: ulid.MustNew(1, nil)}, + isExcluded: true, + }, + { + summary: "block meta not present in the uploaded list, level=1", + uploaded: []ulid.ULID{ulid.MustNew(1, nil), ulid.MustNew(3, nil)}, + meta: tsdb.BlockMeta{ + ULID: ulid.MustNew(2, nil), + Compaction: tsdb.BlockMetaCompaction{Level: 1}, + }, + isExcluded: true, + }, + { + summary: "block meta not present in the uploaded list, level=2", + uploaded: []ulid.ULID{ulid.MustNew(1, nil), ulid.MustNew(3, nil)}, + meta: tsdb.BlockMeta{ + ULID: ulid.MustNew(2, nil), + Compaction: tsdb.BlockMetaCompaction{Level: 2}, + }, + isExcluded: false, + }, + { + summary: "block meta present in the uploaded list", + uploaded: []ulid.ULID{ulid.MustNew(1, nil), ulid.MustNew(2, nil), ulid.MustNew(3, nil)}, + meta: tsdb.BlockMeta{ULID: ulid.MustNew(2, nil)}, + isExcluded: false, + }, + { + summary: "don't read the file if there's valid cache", + setupFn: func(path string) { + // Remove the shipper file, cache should be used instead. + require.NoError(t, os.Remove(path)) + // Set cached values + tsdbDelayCompactLastMeta = &UploadMeta{ + Uploaded: []string{ + ulid.MustNew(1, nil).String(), + ulid.MustNew(2, nil).String(), + ulid.MustNew(3, nil).String(), + }, + } + tsdbDelayCompactLastMetaTime = time.Now().UTC().Add(time.Second * -1) + }, + uploaded: []ulid.ULID{}, + meta: tsdb.BlockMeta{ULID: ulid.MustNew(2, nil)}, + isExcluded: false, + }, + { + summary: "read the file if there's cache but expired", + setupFn: func(_ string) { + // Set the cache but make it too old + tsdbDelayCompactLastMeta = &UploadMeta{ + Uploaded: []string{}, + } + tsdbDelayCompactLastMetaTime = time.Now().UTC().Add(time.Second * -61) + }, + uploaded: []ulid.ULID{ulid.MustNew(1, nil), ulid.MustNew(2, nil), ulid.MustNew(3, nil)}, + meta: tsdb.BlockMeta{ULID: ulid.MustNew(2, nil)}, + isExcluded: false, + }, + } { + t.Run(test.summary, func(t *testing.T) { + dir := t.TempDir() + shipperPath := path.Join(dir, "shipper.json") + + uploaded := make([]string, 0, len(test.uploaded)) + for _, ul := range test.uploaded { + uploaded = append(uploaded, ul.String()) + } + ts := UploadMeta{Uploaded: uploaded} + data, err := json.Marshal(ts) + require.NoError(t, err, "failed to marshall upload meta file") + require.NoError(t, os.WriteFile(shipperPath, data, 0o644), "failed to write upload meta file") + + tsdbDelayCompactLastMeta = nil + tsdbDelayCompactLastMetaTime = time.Time{} + + if test.setupFn != nil { + test.setupFn(shipperPath) + } + + fn := exludeBlocksPendingUpload(promslog.NewNopLogger(), shipperPath) + isExcluded := fn(&test.meta) + require.Equal(t, test.isExcluded, isExcluded) + }) + } +} diff --git a/cmd/promtool/analyze.go b/cmd/promtool/analyze.go index aea72a193b..a725772f5d 100644 --- a/cmd/promtool/analyze.go +++ b/cmd/promtool/analyze.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/analyze_test.go b/cmd/promtool/analyze_test.go index 3de4283a15..d2e81da2c8 100644 --- a/cmd/promtool/analyze_test.go +++ b/cmd/promtool/analyze_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/archive.go b/cmd/promtool/archive.go index 7b565c57cc..23baea2700 100644 --- a/cmd/promtool/archive.go +++ b/cmd/promtool/archive.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/backfill.go b/cmd/promtool/backfill.go index 47de3b5c1c..e7a9a7f18a 100644 --- a/cmd/promtool/backfill.go +++ b/cmd/promtool/backfill.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -27,7 +27,6 @@ import ( "github.com/prometheus/prometheus/model/labels" "github.com/prometheus/prometheus/model/textparse" "github.com/prometheus/prometheus/tsdb" - tsdb_errors "github.com/prometheus/prometheus/tsdb/errors" ) func getMinAndMaxTimestamps(p textparse.Parser) (int64, int64, error) { @@ -94,7 +93,7 @@ func createBlocks(input []byte, mint, maxt, maxBlockDuration int64, maxSamplesIn return err } defer func() { - returnErr = tsdb_errors.NewMulti(returnErr, db.Close()).Err() + returnErr = errors.Join(returnErr, db.Close()) }() var ( @@ -125,7 +124,7 @@ func createBlocks(input []byte, mint, maxt, maxBlockDuration int64, maxSamplesIn return fmt.Errorf("block writer: %w", err) } defer func() { - err = tsdb_errors.NewMulti(err, w.Close()).Err() + err = errors.Join(err, w.Close()) }() ctx := context.Background() diff --git a/cmd/promtool/backfill_test.go b/cmd/promtool/backfill_test.go index 8a599510a9..499b90e99a 100644 --- a/cmd/promtool/backfill_test.go +++ b/cmd/promtool/backfill_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/debug.go b/cmd/promtool/debug.go index 6383aaface..b6e82ef981 100644 --- a/cmd/promtool/debug.go +++ b/cmd/promtool/debug.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/main.go b/cmd/promtool/main.go index 460e47fd25..4dc6c7615f 100644 --- a/cmd/promtool/main.go +++ b/cmd/promtool/main.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -61,7 +61,10 @@ import ( "github.com/prometheus/prometheus/util/documentcli" ) -var promqlEnableDelayedNameRemoval = false +var ( + promqlEnableDelayedNameRemoval = false + promtoolParserOpts parser.Options +) func init() { // This can be removed when the legacy global mode is fully deprecated. @@ -162,7 +165,11 @@ func main() { checkRulesIgnoreUnknownFields := checkRulesCmd.Flag("ignore-unknown-fields", "Ignore unknown fields in the rule files. This is useful when you want to extend rule files with custom metadata. Ensure that those fields are removed before loading them into the Prometheus server as it performs strict checks by default.").Default("false").Bool() checkMetricsCmd := checkCmd.Command("metrics", checkMetricsUsage) - checkMetricsExtended := checkCmd.Flag("extended", "Print extended information related to the cardinality of the metrics.").Bool() + checkMetricsExtended := checkMetricsCmd.Flag("extended", "Print extended information related to the cardinality of the metrics.").Bool() + checkMetricsLint := checkMetricsCmd.Flag( + "lint", + "Linting checks to apply for metrics. Available options are: all, none. Use --lint=none to disable metrics linting.", + ).Default(lintOptionAll).String() agentMode := checkConfigCmd.Flag("agent", "Check config file for Prometheus in Agent mode.").Bool() queryCmd := app.Command("query", "Run query against a Prometheus server.") @@ -257,12 +264,13 @@ func main() { listHumanReadable := tsdbListCmd.Flag("human-readable", "Print human readable values.").Short('r').Bool() listPath := tsdbListCmd.Arg("db path", "Database path (default is "+defaultDBPath+").").Default(defaultDBPath).String() - tsdbDumpCmd := tsdbCmd.Command("dump", "Dump samples from a TSDB.") + tsdbDumpCmd := tsdbCmd.Command("dump", "Dump data (series+samples or optionally just series) from a TSDB.") dumpPath := tsdbDumpCmd.Arg("db path", "Database path (default is "+defaultDBPath+").").Default(defaultDBPath).String() dumpSandboxDirRoot := tsdbDumpCmd.Flag("sandbox-dir-root", "Root directory where a sandbox directory will be created, this sandbox is used in case WAL replay generates chunks (default is the database path). The sandbox is cleaned up at the end.").String() dumpMinTime := tsdbDumpCmd.Flag("min-time", "Minimum timestamp to dump, in milliseconds since the Unix epoch.").Default(strconv.FormatInt(math.MinInt64, 10)).Int64() dumpMaxTime := tsdbDumpCmd.Flag("max-time", "Maximum timestamp to dump, in milliseconds since the Unix epoch.").Default(strconv.FormatInt(math.MaxInt64, 10)).Int64() dumpMatch := tsdbDumpCmd.Flag("match", "Series selector. Can be specified multiple times.").Default("{__name__=~'(?s:.*)'}").Strings() + dumpFormat := tsdbDumpCmd.Flag("format", "Output format of the dump (prom (default) or seriesjson).").Default("prom").Enum("prom", "seriesjson") tsdbDumpOpenMetricsCmd := tsdbCmd.Command("dump-openmetrics", "[Experimental] Dump samples from a TSDB into OpenMetrics text format, excluding native histograms and staleness markers, which are not representable in OpenMetrics.") dumpOpenMetricsPath := tsdbDumpOpenMetricsCmd.Arg("db path", "Database path (default is "+defaultDBPath+").").Default(defaultDBPath).String() @@ -309,7 +317,7 @@ func main() { promQLLabelsDeleteQuery := promQLLabelsDeleteCmd.Arg("query", "PromQL query.").Required().String() promQLLabelsDeleteName := promQLLabelsDeleteCmd.Arg("name", "Name of the label to delete.").Required().String() - featureList := app.Flag("enable-feature", "Comma separated feature names to enable. Valid options: promql-experimental-functions, promql-delayed-name-removal. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details").Default("").Strings() + featureList := app.Flag("enable-feature", "Comma separated feature names to enable. Valid options: promql-experimental-functions, promql-delayed-name-removal, promql-duration-expr, promql-extended-range-selectors. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details").Default("").Strings() documentationCmd := app.Command("write-documentation", "Generate command line documentation. Internal use.").Hidden() @@ -343,9 +351,13 @@ func main() { for o := range strings.SplitSeq(f, ",") { switch o { case "promql-experimental-functions": - parser.EnableExperimentalFunctions = true + promtoolParserOpts.EnableExperimentalFunctions = true case "promql-delayed-name-removal": promqlEnableDelayedNameRemoval = true + case "promql-duration-expr": + promtoolParserOpts.ExperimentalDurationExpr = true + case "promql-extended-range-selectors": + promtoolParserOpts.EnableExtendedRangeSelectors = true case "": continue default: @@ -353,13 +365,14 @@ func main() { } } } + promtoolParser := parser.NewParser(promtoolParserOpts) switch parsedCmd { case sdCheckCmd.FullCommand(): os.Exit(CheckSD(*sdConfigFile, *sdJobName, *sdTimeout, prometheus.DefaultRegisterer)) case checkConfigCmd.FullCommand(): - os.Exit(CheckConfig(*agentMode, *checkConfigSyntaxOnly, newConfigLintConfig(*checkConfigLint, *checkConfigLintFatal, *checkConfigIgnoreUnknownFields, model.UTF8Validation, model.Duration(*checkLookbackDelta)), *configFiles...)) + os.Exit(CheckConfig(*agentMode, *checkConfigSyntaxOnly, newConfigLintConfig(*checkConfigLint, *checkConfigLintFatal, *checkConfigIgnoreUnknownFields, model.UTF8Validation, model.Duration(*checkLookbackDelta)), promtoolParser, *configFiles...)) case checkServerHealthCmd.FullCommand(): os.Exit(checkErr(CheckServerStatus(serverURL, checkHealth, httpRoundTripper))) @@ -371,10 +384,10 @@ func main() { os.Exit(CheckWebConfig(*webConfigFiles...)) case checkRulesCmd.FullCommand(): - os.Exit(CheckRules(newRulesLintConfig(*checkRulesLint, *checkRulesLintFatal, *checkRulesIgnoreUnknownFields, model.UTF8Validation), *ruleFiles...)) + os.Exit(CheckRules(newRulesLintConfig(*checkRulesLint, *checkRulesLintFatal, *checkRulesIgnoreUnknownFields, model.UTF8Validation), promtoolParser, *ruleFiles...)) case checkMetricsCmd.FullCommand(): - os.Exit(CheckMetrics(*checkMetricsExtended)) + os.Exit(CheckMetrics(*checkMetricsExtended, *checkMetricsLint)) case pushMetricsCmd.FullCommand(): os.Exit(PushMetrics(remoteWriteURL, httpRoundTripper, *pushMetricsHeaders, *pushMetricsTimeout, *pushMetricsProtoMsg, *pushMetricsLabels, *metricFiles...)) @@ -411,6 +424,7 @@ func main() { EnableNegativeOffset: true, EnableDelayedNameRemoval: promqlEnableDelayedNameRemoval, }, + promtoolParser, *testRulesRun, *testRulesDiff, *testRulesDebug, @@ -422,15 +436,20 @@ func main() { os.Exit(checkErr(benchmarkWrite(*benchWriteOutPath, *benchSamplesFile, *benchWriteNumMetrics, *benchWriteNumScrapes))) case tsdbAnalyzeCmd.FullCommand(): - os.Exit(checkErr(analyzeBlock(ctx, *analyzePath, *analyzeBlockID, *analyzeLimit, *analyzeRunExtended, *analyzeMatchers))) + os.Exit(checkErr(analyzeBlock(ctx, *analyzePath, *analyzeBlockID, *analyzeLimit, *analyzeRunExtended, *analyzeMatchers, promtoolParser))) case tsdbListCmd.FullCommand(): os.Exit(checkErr(listBlocks(*listPath, *listHumanReadable))) case tsdbDumpCmd.FullCommand(): - os.Exit(checkErr(dumpSamples(ctx, *dumpPath, *dumpSandboxDirRoot, *dumpMinTime, *dumpMaxTime, *dumpMatch, formatSeriesSet))) + format := formatSeriesSet + if *dumpFormat == "seriesjson" { + format = formatSeriesSetLabelsToJSON + } + os.Exit(checkErr(dumpTSDBData(ctx, *dumpPath, *dumpSandboxDirRoot, *dumpMinTime, *dumpMaxTime, *dumpMatch, format, promtoolParser))) + case tsdbDumpOpenMetricsCmd.FullCommand(): - os.Exit(checkErr(dumpSamples(ctx, *dumpOpenMetricsPath, *dumpOpenMetricsSandboxDirRoot, *dumpOpenMetricsMinTime, *dumpOpenMetricsMaxTime, *dumpOpenMetricsMatch, formatSeriesSetOpenMetrics))) + os.Exit(checkErr(dumpTSDBData(ctx, *dumpOpenMetricsPath, *dumpOpenMetricsSandboxDirRoot, *dumpOpenMetricsMinTime, *dumpOpenMetricsMaxTime, *dumpOpenMetricsMatch, formatSeriesSetOpenMetrics, promtoolParser))) // TODO(aSquare14): Work on adding support for custom block size. case openMetricsImportCmd.FullCommand(): os.Exit(backfillOpenMetrics(*importFilePath, *importDBPath, *importHumanReadable, *importQuiet, *maxBlockDuration, *openMetricsLabels)) @@ -446,15 +465,15 @@ func main() { case promQLFormatCmd.FullCommand(): checkExperimental(*experimental) - os.Exit(checkErr(formatPromQL(*promQLFormatQuery))) + os.Exit(checkErr(formatPromQL(*promQLFormatQuery, promtoolParser))) case promQLLabelsSetCmd.FullCommand(): checkExperimental(*experimental) - os.Exit(checkErr(labelsSetPromQL(*promQLLabelsSetQuery, *promQLLabelsSetType, *promQLLabelsSetName, *promQLLabelsSetValue))) + os.Exit(checkErr(labelsSetPromQL(*promQLLabelsSetQuery, *promQLLabelsSetType, *promQLLabelsSetName, *promQLLabelsSetValue, promtoolParser))) case promQLLabelsDeleteCmd.FullCommand(): checkExperimental(*experimental) - os.Exit(checkErr(labelsDeletePromQL(*promQLLabelsDeleteQuery, *promQLLabelsDeleteName))) + os.Exit(checkErr(labelsDeletePromQL(*promQLLabelsDeleteQuery, *promQLLabelsDeleteName, promtoolParser))) } } @@ -579,7 +598,7 @@ func CheckServerStatus(serverURL *url.URL, checkEndpoint string, roundTripper ht } // CheckConfig validates configuration files. -func CheckConfig(agentMode, checkSyntaxOnly bool, lintSettings configLintConfig, files ...string) int { +func CheckConfig(agentMode, checkSyntaxOnly bool, lintSettings configLintConfig, p parser.Parser, files ...string) int { failed := false hasErrors := false @@ -600,7 +619,7 @@ func CheckConfig(agentMode, checkSyntaxOnly bool, lintSettings configLintConfig, if !checkSyntaxOnly { scrapeConfigsFailed := lintScrapeConfigs(scrapeConfigs, lintSettings) failed = failed || scrapeConfigsFailed - rulesFailed, rulesHaveErrors := checkRules(ruleFiles, lintSettings.rulesLintConfig) + rulesFailed, rulesHaveErrors := checkRules(ruleFiles, lintSettings.rulesLintConfig, p) failed = failed || rulesFailed hasErrors = hasErrors || rulesHaveErrors } @@ -827,13 +846,13 @@ func checkSDFile(filename string) ([]*targetgroup.Group, error) { } // CheckRules validates rule files. -func CheckRules(ls rulesLintConfig, files ...string) int { +func CheckRules(ls rulesLintConfig, p parser.Parser, files ...string) int { failed := false hasErrors := false if len(files) == 0 { - failed, hasErrors = checkRulesFromStdin(ls) + failed, hasErrors = checkRulesFromStdin(ls, p) } else { - failed, hasErrors = checkRules(files, ls) + failed, hasErrors = checkRules(files, ls, p) } if failed && hasErrors { @@ -847,7 +866,7 @@ func CheckRules(ls rulesLintConfig, files ...string) int { } // checkRulesFromStdin validates rule from stdin. -func checkRulesFromStdin(ls rulesLintConfig) (bool, bool) { +func checkRulesFromStdin(ls rulesLintConfig, p parser.Parser) (bool, bool) { failed := false hasErrors := false fmt.Println("Checking standard input") @@ -856,7 +875,7 @@ func checkRulesFromStdin(ls rulesLintConfig) (bool, bool) { fmt.Fprintln(os.Stderr, " FAILED:", err) return true, true } - rgs, errs := rulefmt.Parse(data, ls.ignoreUnknownFields, ls.nameValidationScheme) + rgs, errs := rulefmt.Parse(data, ls.ignoreUnknownFields, ls.nameValidationScheme, p) if errs != nil { failed = true fmt.Fprintln(os.Stderr, " FAILED:") @@ -885,12 +904,12 @@ func checkRulesFromStdin(ls rulesLintConfig) (bool, bool) { } // checkRules validates rule files. -func checkRules(files []string, ls rulesLintConfig) (bool, bool) { +func checkRules(files []string, ls rulesLintConfig, p parser.Parser) (bool, bool) { failed := false hasErrors := false for _, f := range files { fmt.Println("Checking", f) - rgs, errs := rulefmt.ParseFile(f, ls.ignoreUnknownFields, ls.nameValidationScheme) + rgs, errs := rulefmt.ParseFile(f, ls.ignoreUnknownFields, ls.nameValidationScheme, p) if errs != nil { failed = true fmt.Fprintln(os.Stderr, " FAILED:") @@ -929,11 +948,11 @@ func checkRuleGroups(rgs *rulefmt.RuleGroups, lintSettings rulesLintConfig) (int dRules := checkDuplicates(rgs.Groups) if len(dRules) != 0 { var errMessage strings.Builder - errMessage.WriteString(fmt.Sprintf("%d duplicate rule(s) found.\n", len(dRules))) + fmt.Fprintf(&errMessage, "%d duplicate rule(s) found.\n", len(dRules)) for _, n := range dRules { - errMessage.WriteString(fmt.Sprintf("Metric: %s\nLabel(s):\n", n.metric)) + fmt.Fprintf(&errMessage, "Metric: %s\nLabel(s):\n", n.metric) n.label.Range(func(l labels.Label) { - errMessage.WriteString(fmt.Sprintf("\t%s: %s\n", l.Name, l.Value)) + fmt.Fprintf(&errMessage, "\t%s: %s\n", l.Name, l.Value) }) } errMessage.WriteString("Might cause inconsistency while recording expressions") @@ -1012,36 +1031,53 @@ func ruleMetric(rule rulefmt.Rule) string { } var checkMetricsUsage = strings.TrimSpace(` -Pass Prometheus metrics over stdin to lint them for consistency and correctness. +Pass Prometheus metrics over stdin to lint them for consistency and correctness, and optionally perform cardinality analysis. examples: $ cat metrics.prom | promtool check metrics -$ curl -s http://localhost:9090/metrics | promtool check metrics +$ curl -s http://localhost:9090/metrics | promtool check metrics --extended + +$ curl -s http://localhost:9100/metrics | promtool check metrics --extended --lint=none `) // CheckMetrics performs a linting pass on input metrics. -func CheckMetrics(extended bool) int { - var buf bytes.Buffer - tee := io.TeeReader(os.Stdin, &buf) - l := promlint.New(tee) - problems, err := l.Lint() - if err != nil { - fmt.Fprintln(os.Stderr, "error while linting:", err) +func CheckMetrics(extended bool, lint string) int { + // Validate that at least one feature is enabled. + if !extended && lint == lintOptionNone { + fmt.Fprintln(os.Stderr, "error: at least one of --extended or linting must be enabled") + fmt.Fprintln(os.Stderr, "Use --extended for cardinality analysis, or remove --lint=none to enable linting") return failureExitCode } - for _, p := range problems { - fmt.Fprintln(os.Stderr, p.Metric, p.Text) + var buf bytes.Buffer + var ( + problems []promlint.Problem + reader io.Reader + err error + ) + + if lint != lintOptionNone { + tee := io.TeeReader(os.Stdin, &buf) + l := promlint.New(tee) + problems, err = l.Lint() + if err != nil { + fmt.Fprintln(os.Stderr, "error while linting:", err) + return failureExitCode + } + for _, p := range problems { + fmt.Fprintln(os.Stderr, p.Metric, p.Text) + } + reader = &buf + } else { + reader = os.Stdin } - if len(problems) > 0 { - return lintErrExitCode - } + hasLintProblems := len(problems) > 0 if extended { - stats, total, err := checkMetricsExtended(&buf) + stats, total, err := checkMetricsExtended(reader) if err != nil { fmt.Fprintln(os.Stderr, err) return failureExitCode @@ -1055,6 +1091,10 @@ func CheckMetrics(extended bool) int { w.Flush() } + if hasLintProblems { + return lintErrExitCode + } + return successExitCode } @@ -1310,8 +1350,8 @@ func checkTargetGroupsForScrapeConfig(targetGroups []*targetgroup.Group, scfg *c return nil } -func formatPromQL(query string) error { - expr, err := parser.ParseExpr(query) +func formatPromQL(query string, p parser.Parser) error { + expr, err := p.ParseExpr(query) if err != nil { return err } @@ -1320,8 +1360,8 @@ func formatPromQL(query string) error { return nil } -func labelsSetPromQL(query, labelMatchType, name, value string) error { - expr, err := parser.ParseExpr(query) +func labelsSetPromQL(query, labelMatchType, name, value string, p parser.Parser) error { + expr, err := p.ParseExpr(query) if err != nil { return err } @@ -1365,8 +1405,8 @@ func labelsSetPromQL(query, labelMatchType, name, value string) error { return nil } -func labelsDeletePromQL(query, name string) error { - expr, err := parser.ParseExpr(query) +func labelsDeletePromQL(query, name string, p parser.Parser) error { + expr, err := p.ParseExpr(query) if err != nil { return err } diff --git a/cmd/promtool/main_test.go b/cmd/promtool/main_test.go index a9a54f6d5f..297dd35d70 100644 --- a/cmd/promtool/main_test.go +++ b/cmd/promtool/main_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -36,6 +37,7 @@ import ( "github.com/prometheus/prometheus/model/labels" "github.com/prometheus/prometheus/model/rulefmt" + "github.com/prometheus/prometheus/promql/parser" "github.com/prometheus/prometheus/promql/promqltest" ) @@ -186,7 +188,7 @@ func TestCheckDuplicates(t *testing.T) { c := test t.Run(c.name, func(t *testing.T) { t.Parallel() - rgs, err := rulefmt.ParseFile(c.ruleFile, false, model.UTF8Validation) + rgs, err := rulefmt.ParseFile(c.ruleFile, false, model.UTF8Validation, parser.NewParser(parser.Options{})) require.Empty(t, err) dups := checkDuplicates(rgs.Groups) require.Equal(t, c.expectedDups, dups) @@ -195,7 +197,7 @@ func TestCheckDuplicates(t *testing.T) { } func BenchmarkCheckDuplicates(b *testing.B) { - rgs, err := rulefmt.ParseFile("./testdata/rules_large.yml", false, model.UTF8Validation) + rgs, err := rulefmt.ParseFile("./testdata/rules_large.yml", false, model.UTF8Validation, parser.NewParser(parser.Options{})) require.Empty(b, err) for b.Loop() { @@ -402,6 +404,99 @@ func TestCheckMetricsExtended(t *testing.T) { }, stats) } +func TestCheckMetricsLintOptions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Skipping on windows") + } + + const testMetrics = ` +# HELP testMetric_CamelCase A test metric with camelCase +# TYPE testMetric_CamelCase gauge +testMetric_CamelCase{label="value1"} 1 +` + + tests := []struct { + name string + lint string + extended bool + wantErrCode int + wantLint bool + wantCard bool + }{ + { + name: "default_all_with_extended", + lint: lintOptionAll, + extended: true, + wantErrCode: lintErrExitCode, + wantLint: true, + wantCard: true, + }, + { + name: "lint_none_with_extended", + lint: lintOptionNone, + extended: true, + wantErrCode: successExitCode, + wantLint: false, + wantCard: true, + }, + { + name: "both_disabled_fails", + lint: lintOptionNone, + extended: false, + wantErrCode: failureExitCode, + wantLint: false, + wantCard: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + _, err = w.WriteString(testMetrics) + require.NoError(t, err) + w.Close() + + oldStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = oldStdin }() + + oldStdout := os.Stdout + oldStderr := os.Stderr + rOut, wOut, err := os.Pipe() + require.NoError(t, err) + rErr, wErr, err := os.Pipe() + require.NoError(t, err) + os.Stdout = wOut + os.Stderr = wErr + + code := CheckMetrics(tt.extended, tt.lint) + + wOut.Close() + wErr.Close() + os.Stdout = oldStdout + os.Stderr = oldStderr + + var outBuf, errBuf bytes.Buffer + _, _ = io.Copy(&outBuf, rOut) + _, _ = io.Copy(&errBuf, rErr) + + require.Equal(t, tt.wantErrCode, code) + if tt.wantLint { + require.Contains(t, errBuf.String(), "testMetric_CamelCase") + } else { + require.NotContains(t, errBuf.String(), "testMetric_CamelCase") + } + + if tt.wantCard { + require.Contains(t, outBuf.String(), "Cardinality") + } else { + require.NotContains(t, outBuf.String(), "Cardinality") + } + }) + } +} + func TestExitCodes(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") @@ -508,7 +603,7 @@ func TestCheckRules(t *testing.T) { defer func(v *os.File) { os.Stdin = v }(os.Stdin) os.Stdin = r - exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation)) + exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation), parser.NewParser(parser.Options{})) require.Equal(t, successExitCode, exitCode) }) @@ -530,7 +625,7 @@ func TestCheckRules(t *testing.T) { defer func(v *os.File) { os.Stdin = v }(os.Stdin) os.Stdin = r - exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation)) + exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation), parser.NewParser(parser.Options{})) require.Equal(t, failureExitCode, exitCode) }) @@ -552,7 +647,7 @@ func TestCheckRules(t *testing.T) { defer func(v *os.File) { os.Stdin = v }(os.Stdin) os.Stdin = r - exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, true, false, model.UTF8Validation)) + exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, true, false, model.UTF8Validation), parser.NewParser(parser.Options{})) require.Equal(t, lintErrExitCode, exitCode) }) } @@ -561,7 +656,7 @@ func TestCheckRulesWithFeatureFlag(t *testing.T) { // As opposed to TestCheckRules calling CheckRules directly we run promtool // so the feature flag parsing can be tested. - args := []string{"-test.main", "--enable-feature=promql-experimental-functions", "check", "rules", "testdata/features.yml"} + args := []string{"-test.main", "--enable-feature=promql-experimental-functions", "--enable-feature=promql-duration-expr", "--enable-feature=promql-extended-range-selectors", "check", "rules", "testdata/features.yml"} tool := exec.Command(promtoolPath, args...) err := tool.Run() require.NoError(t, err) @@ -570,19 +665,19 @@ func TestCheckRulesWithFeatureFlag(t *testing.T) { func TestCheckRulesWithRuleFiles(t *testing.T) { t.Run("rules-good", func(t *testing.T) { t.Parallel() - exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation), "./testdata/rules.yml") + exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation), parser.NewParser(parser.Options{}), "./testdata/rules.yml") require.Equal(t, successExitCode, exitCode) }) t.Run("rules-bad", func(t *testing.T) { t.Parallel() - exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation), "./testdata/rules-bad.yml") + exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, false, false, model.UTF8Validation), parser.NewParser(parser.Options{}), "./testdata/rules-bad.yml") require.Equal(t, failureExitCode, exitCode) }) t.Run("rules-lint-fatal", func(t *testing.T) { t.Parallel() - exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, true, false, model.UTF8Validation), "./testdata/prometheus-rules.lint.yml") + exitCode := CheckRules(newRulesLintConfig(lintOptionDuplicateRules, true, false, model.UTF8Validation), parser.NewParser(parser.Options{}), "./testdata/prometheus-rules.lint.yml") require.Equal(t, lintErrExitCode, exitCode) }) } @@ -611,20 +706,21 @@ func TestCheckScrapeConfigs(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { // Non-fatal linting. - code := CheckConfig(false, false, newConfigLintConfig(lintOptionTooLongScrapeInterval, false, false, model.UTF8Validation, tc.lookbackDelta), "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") + p := parser.NewParser(parser.Options{}) + code := CheckConfig(false, false, newConfigLintConfig(lintOptionTooLongScrapeInterval, false, false, model.UTF8Validation, tc.lookbackDelta), p, "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") require.Equal(t, successExitCode, code, "Non-fatal linting should return success") // Fatal linting. - code = CheckConfig(false, false, newConfigLintConfig(lintOptionTooLongScrapeInterval, true, false, model.UTF8Validation, tc.lookbackDelta), "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") + code = CheckConfig(false, false, newConfigLintConfig(lintOptionTooLongScrapeInterval, true, false, model.UTF8Validation, tc.lookbackDelta), p, "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") if tc.expectError { require.Equal(t, lintErrExitCode, code, "Fatal linting should return error") } else { require.Equal(t, successExitCode, code, "Fatal linting should return success when there are no problems") } // Check syntax only, no linting. - code = CheckConfig(false, true, newConfigLintConfig(lintOptionTooLongScrapeInterval, true, false, model.UTF8Validation, tc.lookbackDelta), "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") + code = CheckConfig(false, true, newConfigLintConfig(lintOptionTooLongScrapeInterval, true, false, model.UTF8Validation, tc.lookbackDelta), p, "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") require.Equal(t, successExitCode, code, "Fatal linting should return success when checking syntax only") // Lint option "none" should disable linting. - code = CheckConfig(false, false, newConfigLintConfig(lintOptionNone+","+lintOptionTooLongScrapeInterval, true, false, model.UTF8Validation, tc.lookbackDelta), "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") + code = CheckConfig(false, false, newConfigLintConfig(lintOptionNone+","+lintOptionTooLongScrapeInterval, true, false, model.UTF8Validation, tc.lookbackDelta), p, "./testdata/prometheus-config.lint.too_long_scrape_interval.yml") require.Equal(t, successExitCode, code, `Fatal linting should return success when lint option "none" is specified`) }) } @@ -640,7 +736,6 @@ func TestTSDBDumpCommand(t *testing.T) { load 1m metric{foo="bar"} 1 2 3 `) - t.Cleanup(func() { storage.Close() }) for _, c := range []struct { name string diff --git a/cmd/promtool/metrics.go b/cmd/promtool/metrics.go index c21ef15fd8..b1a2beb72e 100644 --- a/cmd/promtool/metrics.go +++ b/cmd/promtool/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/metrics_test.go b/cmd/promtool/metrics_test.go index 938f1cadfd..d5a3bf63cc 100644 --- a/cmd/promtool/metrics_test.go +++ b/cmd/promtool/metrics_test.go @@ -1,4 +1,4 @@ -// Copyright 2025 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/query.go b/cmd/promtool/query.go index 0d7cb12cf4..1342f148f8 100644 --- a/cmd/promtool/query.go +++ b/cmd/promtool/query.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/rules.go b/cmd/promtool/rules.go index 98f2c38b58..bb45178e9c 100644 --- a/cmd/promtool/rules.go +++ b/cmd/promtool/rules.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,6 +15,7 @@ package main import ( "context" + "errors" "fmt" "log/slog" "time" @@ -28,7 +29,6 @@ import ( "github.com/prometheus/prometheus/rules" "github.com/prometheus/prometheus/storage" "github.com/prometheus/prometheus/tsdb" - tsdb_errors "github.com/prometheus/prometheus/tsdb/errors" ) const maxSamplesInMemory = 5000 @@ -143,7 +143,7 @@ func (importer *ruleImporter) importRule(ctx context.Context, ruleExpr, ruleName var closed bool defer func() { if !closed { - err = tsdb_errors.NewMulti(err, w.Close()).Err() + err = errors.Join(err, w.Close()) } }() app := newMultipleAppender(ctx, w) @@ -181,7 +181,7 @@ func (importer *ruleImporter) importRule(ctx context.Context, ruleExpr, ruleName if err := app.flushAndCommit(ctx); err != nil { return fmt.Errorf("flush and commit: %w", err) } - err = tsdb_errors.NewMulti(err, w.Close()).Err() + err = errors.Join(err, w.Close()) closed = true } diff --git a/cmd/promtool/rules_test.go b/cmd/promtool/rules_test.go index 6fe7d8c5a1..678e2b4d50 100644 --- a/cmd/promtool/rules_test.go +++ b/cmd/promtool/rules_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/sd.go b/cmd/promtool/sd.go index 884864205c..6b844c699a 100644 --- a/cmd/promtool/sd.go +++ b/cmd/promtool/sd.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/sd_test.go b/cmd/promtool/sd_test.go index e41c9893b2..9f43764f55 100644 --- a/cmd/promtool/sd_test.go +++ b/cmd/promtool/sd_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/testdata/dump-series-1.prom b/cmd/promtool/testdata/dump-series-1.prom new file mode 100644 index 0000000000..5e44c0bf1b --- /dev/null +++ b/cmd/promtool/testdata/dump-series-1.prom @@ -0,0 +1,3 @@ +{"__name__":"heavy_metric","foo":"bar"} +{"__name__":"heavy_metric","foo":"foo"} +{"__name__":"metric","baz":"abc","foo":"bar"} diff --git a/cmd/promtool/testdata/dump-series-2.prom b/cmd/promtool/testdata/dump-series-2.prom new file mode 100644 index 0000000000..fefefa6d1b --- /dev/null +++ b/cmd/promtool/testdata/dump-series-2.prom @@ -0,0 +1,2 @@ +{"__name__":"heavy_metric","foo":"foo"} +{"__name__":"metric","baz":"abc","foo":"bar"} diff --git a/cmd/promtool/testdata/dump-series-3.prom b/cmd/promtool/testdata/dump-series-3.prom new file mode 100644 index 0000000000..dd98e8707d --- /dev/null +++ b/cmd/promtool/testdata/dump-series-3.prom @@ -0,0 +1 @@ +{"__name__":"metric","baz":"abc","foo":"bar"} diff --git a/cmd/promtool/testdata/features.yml b/cmd/promtool/testdata/features.yml index 769f8362bf..946e07d0d7 100644 --- a/cmd/promtool/testdata/features.yml +++ b/cmd/promtool/testdata/features.yml @@ -1,6 +1,10 @@ groups: - name: features rules: - - record: x - # We don't expect anything from this, just want to check the function parses. + # We don't expect anything from these, just want to check the syntax parses. + - record: promql-experimental-functions expr: sort_by_label(up, "instance") + - record: promql-duration-expr + expr: rate(up[1m * 2]) + - record: promql-extended-range-selectors + expr: rate(up[1m] anchored) diff --git a/cmd/promtool/testdata/start-time-test.yml b/cmd/promtool/testdata/start-time-test.yml new file mode 100644 index 0000000000..b7365366f4 --- /dev/null +++ b/cmd/promtool/testdata/start-time-test.yml @@ -0,0 +1,76 @@ +rule_files: + - rules.yml + +evaluation_interval: 1m + +tests: + # Test with default start_time (0 / Unix epoch). + - name: default_start_time + interval: 1m + promql_expr_test: + - expr: time() + eval_time: 0m + exp_samples: + - value: 0 + - expr: time() + eval_time: 5m + exp_samples: + - value: 300 + + # Test with RFC3339 start_timestamp. + - name: rfc3339_start_timestamp + interval: 1m + start_timestamp: "2024-01-01T00:00:00Z" + promql_expr_test: + - expr: time() + eval_time: 0m + exp_samples: + - value: 1704067200 + - expr: time() + eval_time: 5m + exp_samples: + - value: 1704067500 + + # Test with Unix timestamp start_timestamp. + - name: unix_timestamp_start_timestamp + interval: 1m + start_timestamp: 1609459200 + input_series: + - series: test_metric + values: "1 1 1" + promql_expr_test: + - expr: time() + eval_time: 0m + exp_samples: + - value: 1609459200 + - expr: time() + eval_time: 10m + exp_samples: + - value: 1609459800 + + # Test that input series samples are correctly timestamped with custom start_timestamp. + - name: samples_with_start_timestamp + interval: 1m + start_timestamp: "2024-01-01T00:00:00Z" + input_series: + - series: 'my_metric{label="test"}' + values: "10+10x15" + promql_expr_test: + # Query at absolute timestamp (start_timestamp = 1704067200). + - expr: my_metric@1704067200 + eval_time: 5m + exp_samples: + - labels: 'my_metric{label="test"}' + value: 10 + # Query at 2 minutes after start_timestamp (1704067200 + 120 = 1704067320). + - expr: my_metric@1704067320 + eval_time: 5m + exp_samples: + - labels: 'my_metric{label="test"}' + value: 30 + # Verify timestamp() function returns the absolute timestamp. + - expr: timestamp(my_metric) + eval_time: 5m + exp_samples: + - labels: '{label="test"}' + value: 1704067500 diff --git a/cmd/promtool/tsdb.go b/cmd/promtool/tsdb.go index 1da95e862c..f43da0e1d0 100644 --- a/cmd/promtool/tsdb.go +++ b/cmd/promtool/tsdb.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,6 +17,7 @@ import ( "bufio" "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -42,7 +43,6 @@ import ( "github.com/prometheus/prometheus/tsdb" "github.com/prometheus/prometheus/tsdb/chunkenc" "github.com/prometheus/prometheus/tsdb/chunks" - tsdb_errors "github.com/prometheus/prometheus/tsdb/errors" "github.com/prometheus/prometheus/tsdb/fileutil" "github.com/prometheus/prometheus/tsdb/index" ) @@ -159,17 +159,14 @@ func (b *writeBenchmark) ingestScrapes(lbls []labels.Labels, scrapeCount int) (u batch := lbls[:l] lbls = lbls[l:] - wg.Add(1) - go func() { - defer wg.Done() - + wg.Go(func() { n, err := b.ingestScrapesShard(batch, 100, int64(timeDelta*i)) if err != nil { // exitWithError(err) fmt.Println(" err", err) } total.Add(n) - }() + }) } wg.Wait() } @@ -338,7 +335,7 @@ func listBlocks(path string, humanReadable bool) error { return err } defer func() { - err = tsdb_errors.NewMulti(err, db.Close()).Err() + err = errors.Join(err, db.Close()) }() blocks, err := db.Blocks() if err != nil { @@ -408,13 +405,13 @@ func openBlock(path, blockID string) (*tsdb.DBReadOnly, tsdb.BlockReader, error) return db, b, nil } -func analyzeBlock(ctx context.Context, path, blockID string, limit int, runExtended bool, matchers string) error { +func analyzeBlock(ctx context.Context, path, blockID string, limit int, runExtended bool, matchers string, p parser.Parser) error { var ( selectors []*labels.Matcher err error ) if len(matchers) > 0 { - selectors, err = parser.ParseMetricSelector(matchers) + selectors, err = p.ParseMetricSelector(matchers) if err != nil { return err } @@ -424,7 +421,7 @@ func analyzeBlock(ctx context.Context, path, blockID string, limit int, runExten return err } defer func() { - err = tsdb_errors.NewMulti(err, db.Close()).Err() + err = errors.Join(err, db.Close()) }() meta := block.Meta() @@ -478,24 +475,24 @@ func analyzeBlock(ctx context.Context, path, blockID string, limit int, runExten labelpairsCount := map[string]uint64{} entries := 0 var ( - p index.Postings - refs []storage.SeriesRef + postings index.Postings + refs []storage.SeriesRef ) if len(matchers) > 0 { - p, err = tsdb.PostingsForMatchers(ctx, ir, selectors...) + postings, err = tsdb.PostingsForMatchers(ctx, ir, selectors...) if err != nil { return err } // Expand refs first and cache in memory. // So later we don't have to expand again. - refs, err = index.ExpandPostings(p) + refs, err = index.ExpandPostings(postings) if err != nil { return err } fmt.Printf("Matched series: %d\n", len(refs)) - p = index.NewListPostings(refs) + postings = index.NewListPostings(refs) } else { - p, err = ir.Postings(ctx, "", "") // The special all key. + postings, err = ir.Postings(ctx, "", "") // The special all key. if err != nil { return err } @@ -503,8 +500,8 @@ func analyzeBlock(ctx context.Context, path, blockID string, limit int, runExten chks := []chunks.Meta{} builder := labels.ScratchBuilder{} - for p.Next() { - if err = ir.Series(p.At(), &builder, &chks); err != nil { + for postings.Next() { + if err = ir.Series(postings.At(), &builder, &chks); err != nil { return err } // Amount of the block time range not covered by this series. @@ -517,8 +514,8 @@ func analyzeBlock(ctx context.Context, path, blockID string, limit int, runExten entries++ }) } - if p.Err() != nil { - return p.Err() + if postings.Err() != nil { + return postings.Err() } fmt.Printf("Postings (unique label pairs): %d\n", len(labelpairsUncovered)) fmt.Printf("Postings entries (total label pairs): %d\n", entries) @@ -624,7 +621,7 @@ func analyzeCompaction(ctx context.Context, block tsdb.BlockReader, indexr tsdb. return err } defer func() { - err = tsdb_errors.NewMulti(err, chunkr.Close()).Err() + err = errors.Join(err, chunkr.Close()) }() totalChunks := 0 @@ -706,13 +703,13 @@ func analyzeCompaction(ctx context.Context, block tsdb.BlockReader, indexr tsdb. type SeriesSetFormatter func(series storage.SeriesSet) error -func dumpSamples(ctx context.Context, dbDir, sandboxDirRoot string, mint, maxt int64, match []string, formatter SeriesSetFormatter) (err error) { +func dumpTSDBData(ctx context.Context, dbDir, sandboxDirRoot string, mint, maxt int64, match []string, formatter SeriesSetFormatter, p parser.Parser) (err error) { db, err := tsdb.OpenDBReadOnly(dbDir, sandboxDirRoot, nil) if err != nil { return err } defer func() { - err = tsdb_errors.NewMulti(err, db.Close()).Err() + err = errors.Join(err, db.Close()) }() q, err := db.Querier(mint, maxt) if err != nil { @@ -720,7 +717,7 @@ func dumpSamples(ctx context.Context, dbDir, sandboxDirRoot string, mint, maxt i } defer q.Close() - matcherSets, err := parser.ParseMetricSelectors(match) + matcherSets, err := p.ParseMetricSelectors(match) if err != nil { return err } @@ -742,7 +739,7 @@ func dumpSamples(ctx context.Context, dbDir, sandboxDirRoot string, mint, maxt i } if ws := ss.Warnings(); len(ws) > 0 { - return tsdb_errors.NewMulti(ws.AsErrors()...).Err() + return errors.Join(ws.AsErrors()...) } if ss.Err() != nil { @@ -794,6 +791,30 @@ func CondensedString(ls labels.Labels) string { return b.String() } +func formatSeriesSetLabelsToJSON(ss storage.SeriesSet) error { + seriesCache := make(map[string]struct{}) + for ss.Next() { + series := ss.At() + lbs := series.Labels() + + b, err := json.Marshal(lbs) + if err != nil { + return err + } + + if len(b) == 0 { + continue + } + + s := string(b) + if _, ok := seriesCache[s]; !ok { + fmt.Println(s) + seriesCache[s] = struct{}{} + } + } + return nil +} + func formatSeriesSetOpenMetrics(ss storage.SeriesSet) error { for ss.Next() { series := ss.At() diff --git a/cmd/promtool/tsdb_posix_test.go b/cmd/promtool/tsdb_posix_test.go index 8a83aead70..9d0034844f 100644 --- a/cmd/promtool/tsdb_posix_test.go +++ b/cmd/promtool/tsdb_posix_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/cmd/promtool/tsdb_test.go b/cmd/promtool/tsdb_test.go index e745a3fe7a..86d7c67d77 100644 --- a/cmd/promtool/tsdb_test.go +++ b/cmd/promtool/tsdb_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -27,6 +27,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/prometheus/prometheus/promql/parser" "github.com/prometheus/prometheus/promql/promqltest" "github.com/prometheus/prometheus/tsdb" ) @@ -63,7 +64,7 @@ func getDumpedSamples(t *testing.T, databasePath, sandboxDirRoot string, mint, m r, w, _ := os.Pipe() os.Stdout = w - err := dumpSamples( + err := dumpTSDBData( context.Background(), databasePath, sandboxDirRoot, @@ -71,6 +72,7 @@ func getDumpedSamples(t *testing.T, databasePath, sandboxDirRoot string, mint, m maxt, match, formatter, + parser.NewParser(parser.Options{}), ) require.NoError(t, err) @@ -97,7 +99,6 @@ func TestTSDBDump(t *testing.T) { heavy_metric{foo="bar"} 5 4 3 2 1 heavy_metric{foo="foo"} 5 4 3 2 1 `) - t.Cleanup(func() { storage.Close() }) tests := []struct { name string @@ -106,13 +107,15 @@ func TestTSDBDump(t *testing.T) { sandboxDirRoot string match []string expectedDump string + expectedSeries string }{ { - name: "default match", - mint: math.MinInt64, - maxt: math.MaxInt64, - match: []string{"{__name__=~'(?s:.*)'}"}, - expectedDump: "testdata/dump-test-1.prom", + name: "default match", + mint: math.MinInt64, + maxt: math.MaxInt64, + match: []string{"{__name__=~'(?s:.*)'}"}, + expectedDump: "testdata/dump-test-1.prom", + expectedSeries: "testdata/dump-series-1.prom", }, { name: "default match with sandbox dir root set", @@ -121,41 +124,47 @@ func TestTSDBDump(t *testing.T) { sandboxDirRoot: t.TempDir(), match: []string{"{__name__=~'(?s:.*)'}"}, expectedDump: "testdata/dump-test-1.prom", + expectedSeries: "testdata/dump-series-1.prom", }, { - name: "same matcher twice", - mint: math.MinInt64, - maxt: math.MaxInt64, - match: []string{"{foo=~'.+'}", "{foo=~'.+'}"}, - expectedDump: "testdata/dump-test-1.prom", + name: "same matcher twice", + mint: math.MinInt64, + maxt: math.MaxInt64, + match: []string{"{foo=~'.+'}", "{foo=~'.+'}"}, + expectedDump: "testdata/dump-test-1.prom", + expectedSeries: "testdata/dump-series-1.prom", }, { - name: "no duplication", - mint: math.MinInt64, - maxt: math.MaxInt64, - match: []string{"{__name__=~'(?s:.*)'}", "{baz='abc'}"}, - expectedDump: "testdata/dump-test-1.prom", + name: "no duplication", + mint: math.MinInt64, + maxt: math.MaxInt64, + match: []string{"{__name__=~'(?s:.*)'}", "{baz='abc'}"}, + expectedDump: "testdata/dump-test-1.prom", + expectedSeries: "testdata/dump-series-1.prom", }, { - name: "well merged", - mint: math.MinInt64, - maxt: math.MaxInt64, - match: []string{"{__name__='heavy_metric'}", "{baz='abc'}"}, - expectedDump: "testdata/dump-test-1.prom", + name: "well merged", + mint: math.MinInt64, + maxt: math.MaxInt64, + match: []string{"{__name__='heavy_metric'}", "{baz='abc'}"}, + expectedDump: "testdata/dump-test-1.prom", + expectedSeries: "testdata/dump-series-1.prom", }, { - name: "multi matchers", - mint: math.MinInt64, - maxt: math.MaxInt64, - match: []string{"{__name__='heavy_metric',foo='foo'}", "{__name__='metric'}"}, - expectedDump: "testdata/dump-test-2.prom", + name: "multi matchers", + mint: math.MinInt64, + maxt: math.MaxInt64, + match: []string{"{__name__='heavy_metric',foo='foo'}", "{__name__='metric'}"}, + expectedDump: "testdata/dump-test-2.prom", + expectedSeries: "testdata/dump-series-2.prom", }, { - name: "with reduced mint and maxt", - mint: int64(60000), - maxt: int64(120000), - match: []string{"{__name__='metric'}"}, - expectedDump: "testdata/dump-test-3.prom", + name: "with reduced mint and maxt", + mint: int64(60000), + maxt: int64(120000), + match: []string{"{__name__='metric'}"}, + expectedDump: "testdata/dump-test-3.prom", + expectedSeries: "testdata/dump-series-3.prom", }, } for _, tt := range tests { @@ -166,6 +175,12 @@ func TestTSDBDump(t *testing.T) { expectedMetrics = normalizeNewLine(expectedMetrics) // Sort both, because Prometheus does not guarantee the output order. require.Equal(t, sortLines(string(expectedMetrics)), sortLines(dumpedMetrics)) + + dumpedSeries := getDumpedSamples(t, storage.Dir(), tt.sandboxDirRoot, tt.mint, tt.maxt, tt.match, formatSeriesSetLabelsToJSON) + expectedSeries, err := os.ReadFile(tt.expectedSeries) + require.NoError(t, err) + expectedSeries = normalizeNewLine(expectedSeries) + require.Equal(t, sortLines(string(expectedSeries)), sortLines(dumpedSeries)) }) } } @@ -182,7 +197,6 @@ func TestTSDBDumpOpenMetrics(t *testing.T) { my_counter{foo="bar", baz="abc"} 1 2 3 4 5 my_gauge{bar="foo", abc="baz"} 9 8 0 4 7 `) - t.Cleanup(func() { storage.Close() }) tests := []struct { name string diff --git a/cmd/promtool/unittest.go b/cmd/promtool/unittest.go index 15b5171645..dab452af64 100644 --- a/cmd/promtool/unittest.go +++ b/cmd/promtool/unittest.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -47,11 +47,11 @@ import ( // RulesUnitTest does unit testing of rules based on the unit testing files provided. // More info about the file format can be found in the docs. -func RulesUnitTest(queryOpts promqltest.LazyLoaderOpts, runStrings []string, diffFlag, debug, ignoreUnknownFields bool, files ...string) int { - return RulesUnitTestResult(io.Discard, queryOpts, runStrings, diffFlag, debug, ignoreUnknownFields, files...) +func RulesUnitTest(queryOpts promqltest.LazyLoaderOpts, p parser.Parser, runStrings []string, diffFlag, debug, ignoreUnknownFields bool, files ...string) int { + return RulesUnitTestResult(io.Discard, queryOpts, p, runStrings, diffFlag, debug, ignoreUnknownFields, files...) } -func RulesUnitTestResult(results io.Writer, queryOpts promqltest.LazyLoaderOpts, runStrings []string, diffFlag, debug, ignoreUnknownFields bool, files ...string) int { +func RulesUnitTestResult(results io.Writer, queryOpts promqltest.LazyLoaderOpts, p parser.Parser, runStrings []string, diffFlag, debug, ignoreUnknownFields bool, files ...string) int { failed := false junit := &junitxml.JUnitXML{} @@ -61,7 +61,7 @@ func RulesUnitTestResult(results io.Writer, queryOpts promqltest.LazyLoaderOpts, } for _, f := range files { - if errs := ruleUnitTest(f, queryOpts, run, diffFlag, debug, ignoreUnknownFields, junit.Suite(f)); errs != nil { + if errs := ruleUnitTest(f, queryOpts, p, run, diffFlag, debug, ignoreUnknownFields, junit.Suite(f)); errs != nil { fmt.Fprintln(os.Stderr, " FAILED:") for _, e := range errs { fmt.Fprintln(os.Stderr, e.Error()) @@ -83,7 +83,7 @@ func RulesUnitTestResult(results io.Writer, queryOpts promqltest.LazyLoaderOpts, return successExitCode } -func ruleUnitTest(filename string, queryOpts promqltest.LazyLoaderOpts, run *regexp.Regexp, diffFlag, debug, ignoreUnknownFields bool, ts *junitxml.TestSuite) []error { +func ruleUnitTest(filename string, queryOpts promqltest.LazyLoaderOpts, p parser.Parser, run *regexp.Regexp, diffFlag, debug, ignoreUnknownFields bool, ts *junitxml.TestSuite) []error { b, err := os.ReadFile(filename) if err != nil { ts.Abort(err) @@ -132,6 +132,7 @@ func ruleUnitTest(filename string, queryOpts promqltest.LazyLoaderOpts, run *reg if t.Interval == 0 { t.Interval = unitTestInp.EvaluationInterval } + t.parser = p ers := t.test(testname, evalInterval, groupOrderMap, queryOpts, diffFlag, debug, ignoreUnknownFields, unitTestInp.FuzzyCompare, unitTestInp.RuleFiles...) if ers != nil { for _, e := range ers { @@ -188,15 +189,39 @@ func resolveAndGlobFilepaths(baseDir string, utf *unitTestFile) error { return nil } +// testStartTimestamp wraps time.Time to support custom YAML unmarshaling. +// It can parse both RFC3339 timestamps and Unix timestamps. +type testStartTimestamp struct { + time.Time +} + +// UnmarshalYAML implements custom YAML unmarshaling for testStartTimestamp. +// It accepts both RFC3339 formatted strings and numeric Unix timestamps. +func (t *testStartTimestamp) UnmarshalYAML(unmarshal func(any) error) error { + var s string + if err := unmarshal(&s); err != nil { + return err + } + parsed, err := parseTime(s) + if err != nil { + return err + } + t.Time = parsed + return nil +} + // testGroup is a group of input series and tests associated with it. type testGroup struct { - Interval model.Duration `yaml:"interval"` - InputSeries []series `yaml:"input_series"` - AlertRuleTests []alertTestCase `yaml:"alert_rule_test,omitempty"` - PromqlExprTests []promqlTestCase `yaml:"promql_expr_test,omitempty"` - ExternalLabels labels.Labels `yaml:"external_labels,omitempty"` - ExternalURL string `yaml:"external_url,omitempty"` - TestGroupName string `yaml:"name,omitempty"` + Interval model.Duration `yaml:"interval"` + InputSeries []series `yaml:"input_series"` + AlertRuleTests []alertTestCase `yaml:"alert_rule_test,omitempty"` + PromqlExprTests []promqlTestCase `yaml:"promql_expr_test,omitempty"` + ExternalLabels labels.Labels `yaml:"external_labels,omitempty"` + ExternalURL string `yaml:"external_url,omitempty"` + TestGroupName string `yaml:"name,omitempty"` + StartTimestamp testStartTimestamp `yaml:"start_timestamp,omitempty"` + + parser parser.Parser `yaml:"-"` } // test performs the unit tests. @@ -209,6 +234,8 @@ func (tg *testGroup) test(testname string, evalInterval time.Duration, groupOrde }() } // Setup testing suite. + // Set the start time from the test group. + queryOpts.StartTime = tg.StartTimestamp.Time suite, err := promqltest.NewLazyLoader(tg.seriesLoadingString(), queryOpts) if err != nil { return []error{err} @@ -228,6 +255,7 @@ func (tg *testGroup) test(testname string, evalInterval time.Duration, groupOrde Context: context.Background(), NotifyFunc: func(context.Context, string, ...*rules.Alert) {}, Logger: promslog.NewNopLogger(), + Parser: tg.parser, } m := rules.NewManager(opts) groupsMap, ers := m.LoadGroups(time.Duration(tg.Interval), tg.ExternalLabels, tg.ExternalURL, nil, ignoreUnknownFields, ruleFiles...) @@ -237,7 +265,12 @@ func (tg *testGroup) test(testname string, evalInterval time.Duration, groupOrde groups := orderedGroups(groupsMap, groupOrderMap) // Bounds for evaluating the rules. - mint := time.Unix(0, 0).UTC() + var mint time.Time + if tg.StartTimestamp.IsZero() { + mint = time.Unix(0, 0).UTC() + } else { + mint = tg.StartTimestamp.Time + } maxt := mint.Add(tg.maxEvalTime()) // Optional floating point compare fuzzing. @@ -453,10 +486,10 @@ Outer: var expSamples []parsedSample for _, s := range testCase.ExpSamples { - lb, err := parser.ParseMetric(s.Labels) + lb, err := tg.parser.ParseMetric(s.Labels) var hist *histogram.FloatHistogram if err == nil && s.Histogram != "" { - _, values, parseErr := parser.ParseSeriesDesc("{} " + s.Histogram) + _, values, parseErr := tg.parser.ParseSeriesDesc("{} " + s.Histogram) switch { case parseErr != nil: err = parseErr @@ -528,9 +561,9 @@ Outer: // seriesLoadingString returns the input series in PromQL notation. func (tg *testGroup) seriesLoadingString() string { var result strings.Builder - result.WriteString(fmt.Sprintf("load %v\n", shortDuration(tg.Interval))) + fmt.Fprintf(&result, "load %v\n", shortDuration(tg.Interval)) for _, is := range tg.InputSeries { - result.WriteString(fmt.Sprintf(" %v %v\n", is.Series, is.Values)) + fmt.Fprintf(&result, " %v %v\n", is.Series, is.Values) } return result.String() } @@ -631,13 +664,14 @@ func (la labelsAndAnnotations) String() string { if len(la) == 0 { return "[]" } - s := "[\n0:" + indentLines("\n"+la[0].String(), " ") + var s strings.Builder + s.WriteString("[\n0:" + indentLines("\n"+la[0].String(), " ")) for i, l := range la[1:] { - s += ",\n" + strconv.Itoa(i+1) + ":" + indentLines("\n"+l.String(), " ") + s.WriteString(",\n" + strconv.Itoa(i+1) + ":" + indentLines("\n"+l.String(), " ")) } - s += "\n]" + s.WriteString("\n]") - return s + return s.String() } type labelAndAnnotation struct { @@ -688,11 +722,12 @@ func parsedSamplesString(pss []parsedSample) string { if len(pss) == 0 { return "nil" } - s := pss[0].String() + var s strings.Builder + s.WriteString(pss[0].String()) for _, ps := range pss[1:] { - s += ", " + ps.String() + s.WriteString(", " + ps.String()) } - return s + return s.String() } func (ps *parsedSample) String() string { diff --git a/cmd/promtool/unittest_test.go b/cmd/promtool/unittest_test.go index 566e0acbc6..ce317e5e41 100644 --- a/cmd/promtool/unittest_test.go +++ b/cmd/promtool/unittest_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/prometheus/prometheus/promql/parser" "github.com/prometheus/prometheus/promql/promqltest" "github.com/prometheus/prometheus/util/junitxml" ) @@ -129,6 +130,16 @@ func TestRulesUnitTest(t *testing.T) { }, want: 0, }, + { + name: "Start time tests", + args: args{ + files: []string{"./testdata/start-time-test.yml"}, + }, + queryOpts: promqltest.LazyLoaderOpts{ + EnableAtModifier: true, + }, + want: 0, + }, } reuseFiles := []string{} reuseCount := [2]int{} @@ -143,7 +154,7 @@ func TestRulesUnitTest(t *testing.T) { } t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := RulesUnitTest(tt.queryOpts, nil, false, false, false, tt.args.files...); got != tt.want { + if got := RulesUnitTest(tt.queryOpts, parser.NewParser(parser.Options{}), nil, false, false, false, tt.args.files...); got != tt.want { t.Errorf("RulesUnitTest() = %v, want %v", got, tt.want) } }) @@ -151,7 +162,7 @@ func TestRulesUnitTest(t *testing.T) { t.Run("Junit xml output ", func(t *testing.T) { t.Parallel() var buf bytes.Buffer - if got := RulesUnitTestResult(&buf, promqltest.LazyLoaderOpts{}, nil, false, false, false, reuseFiles...); got != 1 { + if got := RulesUnitTestResult(&buf, promqltest.LazyLoaderOpts{}, parser.NewParser(parser.Options{}), nil, false, false, false, reuseFiles...); got != 1 { t.Errorf("RulesUnitTestResults() = %v, want 1", got) } var test junitxml.JUnitXML @@ -267,7 +278,7 @@ func TestRulesUnitTestRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := RulesUnitTest(tt.queryOpts, tt.args.run, false, false, tt.ignoreUnknownFields, tt.args.files...) + got := RulesUnitTest(tt.queryOpts, parser.NewParser(parser.Options{}), tt.args.run, false, false, tt.ignoreUnknownFields, tt.args.files...) require.Equal(t, tt.want, got) }) } diff --git a/compliance/go.mod b/compliance/go.mod new file mode 100644 index 0000000000..54adc20b6c --- /dev/null +++ b/compliance/go.mod @@ -0,0 +1,26 @@ +module compliance + +go 1.25.5 + +require github.com/prometheus/compliance/remotewrite v0.0.0-20260220101514-bccaa3a70275 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect + github.com/klauspost/compress v1.18.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/oklog/run v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang/exp v0.0.0-20250914183048-a974e0d45e0a // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/prometheus v0.307.4-0.20251119130332-1174b0ce4f1f // indirect + github.com/stretchr/testify v1.11.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/compliance/go.sum b/compliance/go.sum new file mode 100644 index 0000000000..6f273f49bd --- /dev/null +++ b/compliance/go.sum @@ -0,0 +1,79 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= +github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang/exp v0.0.0-20250914183048-a974e0d45e0a h1:RF1vfKM34/3DbGNis22BGd6sDDY3XBi0eM7pYqmOEO0= +github.com/prometheus/client_golang/exp v0.0.0-20250914183048-a974e0d45e0a/go.mod h1:FGJuwvfcPY0V5enm+w8zF1RNS062yugQtPPQp1c4Io4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/compliance/remotewrite v0.0.0-20260220101514-bccaa3a70275 h1:NLTtFqM00EuqtisYX9P+hQkjoxNxsR2oUQWDluyD2Xw= +github.com/prometheus/compliance/remotewrite v0.0.0-20260220101514-bccaa3a70275/go.mod h1:VEPZGvpSBbzTKc5acnBj9ng4gfo1DZ4qBsCQnoNFiSc= +github.com/prometheus/prometheus v0.307.4-0.20251119130332-1174b0ce4f1f h1:ERPCnBglv9Z4IjkEBTNbcHmZPlryMldXVWLkk7TeBIY= +github.com/prometheus/prometheus v0.307.4-0.20251119130332-1174b0ce4f1f/go.mod h1:7hcXiGf9AXIKW2ehWWzxkvRYJTGmc2StUIJ8mprfxjg= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/compliance/remote_write_sender_test.go b/compliance/remote_write_sender_test.go new file mode 100644 index 0000000000..6840132bd3 --- /dev/null +++ b/compliance/remote_write_sender_test.go @@ -0,0 +1,93 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compliance + +import ( + "bytes" + "context" + "fmt" + "html/template" + "os" + "path/filepath" + "testing" + + "github.com/prometheus/compliance/remotewrite/sender" +) + +const ( + scrapeConfigTemplate = ` +global: + scrape_interval: 1s + +remote_write: + - url: "{{.RemoteWriteEndpointURL}}" + protobuf_message: "{{.RemoteWriteMessage}}" + send_exemplars: true + queue_config: + retry_on_http_429: true + metadata_config: + send: true + +scrape_configs: + - job_name: "{{.ScrapeTargetJobName}}" + scrape_interval: 1s + scrape_protocols: + - PrometheusProto + - OpenMetricsText1.0.0 + - PrometheusText0.0.4 + static_configs: + - targets: ["{{.ScrapeTargetHostPort}}"] +` +) + +var scrapeConfigTmpl = template.Must(template.New("config").Parse(scrapeConfigTemplate)) + +type internalPrometheus struct{} + +func (p internalPrometheus) Name() string { return "internal-prometheus" } + +// Run runs a cmd/prometheus main package as a test sender target, until ctx is done. +func (p internalPrometheus) Run(ctx context.Context, opts sender.Options) error { + var buf bytes.Buffer + if err := scrapeConfigTmpl.Execute(&buf, opts); err != nil { + return fmt.Errorf("failed to execute config template: %w", err) + } + + dir, err := os.MkdirTemp("", "test-*") + if err != nil { + return err + } + configFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configFile, buf.Bytes(), 0o600); err != nil { + return err + } + defer os.RemoveAll(dir) + + return sender.RunCommand(ctx, "../cmd/prometheus", nil, + "go", "run", ".", + "--web.listen-address=0.0.0.0:0", + fmt.Sprintf("--storage.tsdb.path=%v", dir), + fmt.Sprintf("--config.file=%s", configFile), + // Set important flags for the full remote write compliance: + "--enable-feature=st-storage", + ) +} + +var _ sender.Sender = internalPrometheus{} + +// TestRemoteWriteSender runs remote write sender compliance tests defined in +// https://github.com/prometheus/compliance/tree/main/remotewrite/sender +func TestRemoteWriteSender(t *testing.T) { + sender.RunTests(t, internalPrometheus{}, sender.ComplianceTests()) +} diff --git a/config/config.go b/config/config.go index 30c8a8ed21..d721d7fb86 100644 --- a/config/config.go +++ b/config/config.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -149,6 +149,10 @@ func LoadFile(filename string, agentMode bool, logger *slog.Logger) (*Config, er return cfg, nil } +func boolPtr(b bool) *bool { + return &b +} + // The defaults applied before parsing the respective config sections. var ( // DefaultConfig is the default top-level configuration. @@ -158,7 +162,6 @@ var ( OTLPConfig: DefaultOTLPConfig, } - f bool // DefaultGlobalConfig is the default global configuration. DefaultGlobalConfig = GlobalConfig{ ScrapeInterval: model.Duration(1 * time.Minute), @@ -173,9 +176,10 @@ var ( ScrapeProtocols: nil, // When the native histogram feature flag is enabled, // ScrapeNativeHistograms default changes to true. - ScrapeNativeHistograms: &f, + ScrapeNativeHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: false, AlwaysScrapeClassicHistograms: false, + ExtraScrapeMetrics: boolPtr(false), MetricNameValidationScheme: model.UTF8Validation, MetricNameEscapingScheme: model.AllowUTF8, } @@ -513,6 +517,10 @@ type GlobalConfig struct { ConvertClassicHistogramsToNHCB bool `yaml:"convert_classic_histograms_to_nhcb,omitempty"` // Whether to scrape a classic histogram, even if it is also exposed as a native histogram. AlwaysScrapeClassicHistograms bool `yaml:"always_scrape_classic_histograms,omitempty"` + // Whether to enable additional scrape metrics. + // When enabled, Prometheus stores samples for scrape_timeout_seconds, + // scrape_sample_limit, and scrape_body_size_bytes. + ExtraScrapeMetrics *bool `yaml:"extra_scrape_metrics,omitempty"` } // ScrapeProtocol represents supported protocol for scraping metrics. @@ -652,6 +660,9 @@ func (c *GlobalConfig) UnmarshalYAML(unmarshal func(any) error) error { if gc.ScrapeNativeHistograms == nil { gc.ScrapeNativeHistograms = DefaultGlobalConfig.ScrapeNativeHistograms } + if gc.ExtraScrapeMetrics == nil { + gc.ExtraScrapeMetrics = DefaultGlobalConfig.ExtraScrapeMetrics + } if gc.ScrapeProtocols == nil { if DefaultGlobalConfig.ScrapeProtocols != nil { // This is the case where the defaults are set due to a feature flag. @@ -687,7 +698,17 @@ func (c *GlobalConfig) isZero() bool { c.ScrapeProtocols == nil && c.ScrapeNativeHistograms == nil && !c.ConvertClassicHistogramsToNHCB && - !c.AlwaysScrapeClassicHistograms + !c.AlwaysScrapeClassicHistograms && + c.BodySizeLimit == 0 && + c.SampleLimit == 0 && + c.TargetLimit == 0 && + c.LabelLimit == 0 && + c.LabelNameLengthLimit == 0 && + c.LabelValueLengthLimit == 0 && + c.KeepDroppedTargets == 0 && + c.MetricNameValidationScheme == model.UnsetValidation && + c.MetricNameEscapingScheme == "" && + c.ExtraScrapeMetrics == nil } const DefaultGoGCPercentage = 75 @@ -796,6 +817,11 @@ type ScrapeConfig struct { // blank in config files but must have a value if a ScrapeConfig is created // programmatically. MetricNameEscapingScheme string `yaml:"metric_name_escaping_scheme,omitempty"` + // Whether to enable additional scrape metrics. + // When enabled, Prometheus stores samples for scrape_timeout_seconds, + // scrape_sample_limit, and scrape_body_size_bytes. + // If not set (nil), inherits the value from the global configuration. + ExtraScrapeMetrics *bool `yaml:"extra_scrape_metrics,omitempty"` // We cannot do proper Go type embedding below as the parser will then parse // values arbitrarily into the overflow maps of further-down types. @@ -897,6 +923,9 @@ func (c *ScrapeConfig) Validate(globalConfig GlobalConfig) error { if c.ScrapeNativeHistograms == nil { c.ScrapeNativeHistograms = globalConfig.ScrapeNativeHistograms } + if c.ExtraScrapeMetrics == nil { + c.ExtraScrapeMetrics = globalConfig.ExtraScrapeMetrics + } if c.ScrapeProtocols == nil { switch { @@ -1022,7 +1051,7 @@ func ToEscapingScheme(s string, v model.ValidationScheme) (model.EscapingScheme, case model.LegacyValidation: return model.UnderscoreEscaping, nil case model.UnsetValidation: - return model.NoEscaping, fmt.Errorf("v is unset: %s", v) + return model.NoEscaping, fmt.Errorf("ValidationScheme is unset: %s", v) default: panic(fmt.Errorf("unhandled validation scheme: %s", v)) } @@ -1045,6 +1074,11 @@ func (c *ScrapeConfig) AlwaysScrapeClassicHistogramsEnabled() bool { return c.AlwaysScrapeClassicHistograms != nil && *c.AlwaysScrapeClassicHistograms } +// ExtraScrapeMetricsEnabled returns whether to enable extra scrape metrics. +func (c *ScrapeConfig) ExtraScrapeMetricsEnabled() bool { + return c.ExtraScrapeMetrics != nil && *c.ExtraScrapeMetrics +} + // StorageConfig configures runtime reloadable configuration options. type StorageConfig struct { TSDBConfig *TSDBConfig `yaml:"tsdb,omitempty"` @@ -1073,6 +1107,10 @@ type TSDBConfig struct { // This should not be used directly and must be converted into OutOfOrderTimeWindow. OutOfOrderTimeWindowFlag model.Duration `yaml:"out_of_order_time_window,omitempty"` + // StaleSeriesCompactionThreshold is a number between 0.0-1.0 indicating the % of stale series in + // the in-memory Head block. If the % of stale series crosses this threshold, stale series compaction is run immediately. + StaleSeriesCompactionThreshold float64 `yaml:"stale_series_compaction_threshold,omitempty"` + Retention *TSDBRetentionConfig `yaml:"retention,omitempty"` } diff --git a/config/config_default_test.go b/config/config_default_test.go index e5f43e1f50..91c290ae4e 100644 --- a/config/config_default_test.go +++ b/config/config_default_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/config/config_test.go b/config/config_test.go index 28c8f2196d..968b563e1e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -74,10 +74,6 @@ func mustParseURL(u string) *config.URL { return &config.URL{URL: parsed} } -func boolPtr(b bool) *bool { - return &b -} - const ( globBodySizeLimit = 15 * units.MiB globSampleLimit = 1500 @@ -109,6 +105,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: false, ConvertClassicHistogramsToNHCB: false, + ExtraScrapeMetrics: boolPtr(false), MetricNameValidationScheme: model.UTF8Validation, }, @@ -236,6 +233,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -360,6 +358,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), HTTPClientConfig: config.HTTPClientConfig{ BasicAuth: &config.BasicAuth{ @@ -470,6 +469,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -532,6 +532,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: "/metrics", Scheme: "http", @@ -571,6 +572,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -616,6 +618,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -661,6 +664,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -696,6 +700,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -739,6 +744,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -779,6 +785,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -826,6 +833,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -863,6 +871,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -903,6 +912,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -936,6 +946,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -972,6 +983,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: "/federate", Scheme: DefaultScrapeConfig.Scheme, @@ -1008,6 +1020,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1044,6 +1057,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1077,6 +1091,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1118,6 +1133,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1158,6 +1174,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1195,6 +1212,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1231,6 +1249,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1271,6 +1290,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1314,6 +1334,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(true), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1377,6 +1398,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1410,6 +1432,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), HTTPClientConfig: config.DefaultHTTPClientConfig, MetricsPath: DefaultScrapeConfig.MetricsPath, @@ -1454,6 +1477,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), HTTPClientConfig: config.DefaultHTTPClientConfig, MetricsPath: DefaultScrapeConfig.MetricsPath, @@ -1504,6 +1528,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1544,6 +1569,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1585,6 +1611,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), HTTPClientConfig: config.DefaultHTTPClientConfig, MetricsPath: DefaultScrapeConfig.MetricsPath, @@ -1621,6 +1648,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1659,6 +1687,7 @@ var expectedConf = &Config{ ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -1704,8 +1733,9 @@ var expectedConf = &Config{ }, StorageConfig: StorageConfig{ TSDBConfig: &TSDBConfig{ - OutOfOrderTimeWindow: 30 * time.Minute.Milliseconds(), - OutOfOrderTimeWindowFlag: model.Duration(30 * time.Minute), + OutOfOrderTimeWindow: 30 * time.Minute.Milliseconds(), + OutOfOrderTimeWindowFlag: model.Duration(30 * time.Minute), + StaleSeriesCompactionThreshold: 0.5, Retention: &TSDBRetentionConfig{ Time: model.Duration(24 * time.Hour), Size: 1 * units.GiB, @@ -2663,12 +2693,87 @@ func TestAgentMode(t *testing.T) { ) } -func TestEmptyGlobalBlock(t *testing.T) { - c, err := Load("global:\n", promslog.NewNopLogger()) - require.NoError(t, err) - exp := DefaultConfig - exp.loaded = true - require.Equal(t, exp, *c) +func TestGlobalConfig(t *testing.T) { + t.Run("empty block restores defaults", func(t *testing.T) { + c, err := Load("global:\n", promslog.NewNopLogger()) + require.NoError(t, err) + exp := DefaultConfig + exp.loaded = true + require.Equal(t, exp, *c) + }) + + // Verify that isZero() correctly identifies non-zero configurations for all + // fields in GlobalConfig. This is important because isZero() is used during + // YAML unmarshaling to detect empty global blocks that should be replaced + // with defaults. + t.Run("isZero", func(t *testing.T) { + for _, tc := range []struct { + name string + config GlobalConfig + expectZero bool + }{ + { + name: "empty GlobalConfig", + config: GlobalConfig{}, + expectZero: true, + }, + { + name: "ScrapeInterval set", + config: GlobalConfig{ScrapeInterval: model.Duration(30 * time.Second)}, + expectZero: false, + }, + { + name: "BodySizeLimit set", + config: GlobalConfig{BodySizeLimit: 1 * units.MiB}, + expectZero: false, + }, + { + name: "SampleLimit set", + config: GlobalConfig{SampleLimit: 1000}, + expectZero: false, + }, + { + name: "TargetLimit set", + config: GlobalConfig{TargetLimit: 500}, + expectZero: false, + }, + { + name: "LabelLimit set", + config: GlobalConfig{LabelLimit: 100}, + expectZero: false, + }, + { + name: "LabelNameLengthLimit set", + config: GlobalConfig{LabelNameLengthLimit: 50}, + expectZero: false, + }, + { + name: "LabelValueLengthLimit set", + config: GlobalConfig{LabelValueLengthLimit: 200}, + expectZero: false, + }, + { + name: "KeepDroppedTargets set", + config: GlobalConfig{KeepDroppedTargets: 10}, + expectZero: false, + }, + { + name: "MetricNameValidationScheme set", + config: GlobalConfig{MetricNameValidationScheme: model.LegacyValidation}, + expectZero: false, + }, + { + name: "MetricNameEscapingScheme set", + config: GlobalConfig{MetricNameEscapingScheme: model.EscapeUnderscores}, + expectZero: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + result := tc.config.isZero() + require.Equal(t, tc.expectZero, result) + }) + } + }) } // ScrapeConfigOptions contains options for creating a scrape config. @@ -2680,6 +2785,7 @@ type ScrapeConfigOptions struct { ScrapeNativeHistograms bool AlwaysScrapeClassicHistograms bool ConvertClassicHistToNHCB bool + ExtraScrapeMetrics bool } func TestGetScrapeConfigs(t *testing.T) { @@ -2713,6 +2819,7 @@ func TestGetScrapeConfigs(t *testing.T) { ScrapeNativeHistograms: boolPtr(opts.ScrapeNativeHistograms), AlwaysScrapeClassicHistograms: boolPtr(opts.AlwaysScrapeClassicHistograms), ConvertClassicHistogramsToNHCB: boolPtr(opts.ConvertClassicHistToNHCB), + ExtraScrapeMetrics: boolPtr(opts.ExtraScrapeMetrics), } if opts.ScrapeProtocols == nil { sc.ScrapeProtocols = DefaultScrapeProtocols @@ -2796,6 +2903,7 @@ func TestGetScrapeConfigs(t *testing.T) { ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), MetricsPath: DefaultScrapeConfig.MetricsPath, Scheme: DefaultScrapeConfig.Scheme, @@ -2834,6 +2942,7 @@ func TestGetScrapeConfigs(t *testing.T) { ScrapeNativeHistograms: boolPtr(false), AlwaysScrapeClassicHistograms: boolPtr(false), ConvertClassicHistogramsToNHCB: boolPtr(false), + ExtraScrapeMetrics: boolPtr(false), HTTPClientConfig: config.HTTPClientConfig{ TLSConfig: config.TLSConfig{ @@ -2946,6 +3055,26 @@ func TestGetScrapeConfigs(t *testing.T) { configFile: "testdata/global_scrape_protocols_and_local_disable_scrape_native_hist.good.yml", expectedResult: []*ScrapeConfig{sc(ScrapeConfigOptions{JobName: "prometheus", ScrapeInterval: model.Duration(60 * time.Second), ScrapeTimeout: model.Duration(10 * time.Second), ScrapeNativeHistograms: false, ScrapeProtocols: []ScrapeProtocol{PrometheusText0_0_4}})}, }, + { + name: "A global config that enables extra scrape metrics", + configFile: "testdata/global_enable_extra_scrape_metrics.good.yml", + expectedResult: []*ScrapeConfig{sc(ScrapeConfigOptions{JobName: "prometheus", ScrapeInterval: model.Duration(60 * time.Second), ScrapeTimeout: model.Duration(10 * time.Second), ExtraScrapeMetrics: true})}, + }, + { + name: "A global config that disables extra scrape metrics", + configFile: "testdata/global_disable_extra_scrape_metrics.good.yml", + expectedResult: []*ScrapeConfig{sc(ScrapeConfigOptions{JobName: "prometheus", ScrapeInterval: model.Duration(60 * time.Second), ScrapeTimeout: model.Duration(10 * time.Second), ExtraScrapeMetrics: false})}, + }, + { + name: "A global config that disables extra scrape metrics and scrape config that enables it", + configFile: "testdata/local_enable_extra_scrape_metrics.good.yml", + expectedResult: []*ScrapeConfig{sc(ScrapeConfigOptions{JobName: "prometheus", ScrapeInterval: model.Duration(60 * time.Second), ScrapeTimeout: model.Duration(10 * time.Second), ExtraScrapeMetrics: true})}, + }, + { + name: "A global config that enables extra scrape metrics and scrape config that disables it", + configFile: "testdata/local_disable_extra_scrape_metrics.good.yml", + expectedResult: []*ScrapeConfig{sc(ScrapeConfigOptions{JobName: "prometheus", ScrapeInterval: model.Duration(60 * time.Second), ScrapeTimeout: model.Duration(10 * time.Second), ExtraScrapeMetrics: false})}, + }, } for _, tc := range testCases { @@ -2962,6 +3091,99 @@ func TestGetScrapeConfigs(t *testing.T) { } } +func TestExtraScrapeMetrics(t *testing.T) { + tests := []struct { + name string + config string + expectGlobal *bool + expectEnabled bool + }{ + { + name: "default values (not set)", + config: ` +scrape_configs: + - job_name: test + static_configs: + - targets: ['localhost:9090'] +`, + expectGlobal: boolPtr(false), // inherits from DefaultGlobalConfig + expectEnabled: false, + }, + { + name: "global enabled", + config: ` +global: + extra_scrape_metrics: true +scrape_configs: + - job_name: test + static_configs: + - targets: ['localhost:9090'] +`, + expectGlobal: boolPtr(true), + expectEnabled: true, + }, + { + name: "global disabled", + config: ` +global: + extra_scrape_metrics: false +scrape_configs: + - job_name: test + static_configs: + - targets: ['localhost:9090'] +`, + expectGlobal: boolPtr(false), + expectEnabled: false, + }, + { + name: "scrape override enabled", + config: ` +global: + extra_scrape_metrics: false +scrape_configs: + - job_name: test + extra_scrape_metrics: true + static_configs: + - targets: ['localhost:9090'] +`, + expectGlobal: boolPtr(false), + expectEnabled: true, + }, + { + name: "scrape override disabled", + config: ` +global: + extra_scrape_metrics: true +scrape_configs: + - job_name: test + extra_scrape_metrics: false + static_configs: + - targets: ['localhost:9090'] +`, + expectGlobal: boolPtr(true), + expectEnabled: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg, err := Load(tc.config, promslog.NewNopLogger()) + require.NoError(t, err) + + // Check global config + require.Equal(t, tc.expectGlobal, cfg.GlobalConfig.ExtraScrapeMetrics) + + // Check scrape config + scfgs, err := cfg.GetScrapeConfigs() + require.NoError(t, err) + require.Len(t, scfgs, 1) + + // Check the effective value via the helper method + require.Equal(t, tc.expectEnabled, scfgs[0].ExtraScrapeMetricsEnabled()) + }) + } +} + func kubernetesSDHostURL() config.URL { tURL, _ := url.Parse("https://localhost:1234") return config.URL{URL: tURL} diff --git a/config/config_windows_test.go b/config/config_windows_test.go index 9d338b99e7..72a56ff41a 100644 --- a/config/config_windows_test.go +++ b/config/config_windows_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/config/reload.go b/config/reload.go index 07a077a6a9..a250693169 100644 --- a/config/reload.go +++ b/config/reload.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/config/reload_test.go b/config/reload_test.go index 3e77260ab3..cb60d47651 100644 --- a/config/reload_test.go +++ b/config/reload_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/config/testdata/conf.good.yml b/config/testdata/conf.good.yml index 7aa53b3b74..96bf9e2b33 100644 --- a/config/testdata/conf.good.yml +++ b/config/testdata/conf.good.yml @@ -453,6 +453,7 @@ alerting: storage: tsdb: out_of_order_time_window: 30m + stale_series_compaction_threshold: 0.5 retention: time: 1d size: 1GB diff --git a/config/testdata/global_disable_extra_scrape_metrics.good.yml b/config/testdata/global_disable_extra_scrape_metrics.good.yml new file mode 100644 index 0000000000..26c6e4b8b5 --- /dev/null +++ b/config/testdata/global_disable_extra_scrape_metrics.good.yml @@ -0,0 +1,6 @@ +global: + extra_scrape_metrics: false +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['localhost:8080'] diff --git a/config/testdata/global_enable_extra_scrape_metrics.good.yml b/config/testdata/global_enable_extra_scrape_metrics.good.yml new file mode 100644 index 0000000000..1d7ea2db1c --- /dev/null +++ b/config/testdata/global_enable_extra_scrape_metrics.good.yml @@ -0,0 +1,6 @@ +global: + extra_scrape_metrics: true +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['localhost:8080'] diff --git a/config/testdata/local_disable_extra_scrape_metrics.good.yml b/config/testdata/local_disable_extra_scrape_metrics.good.yml new file mode 100644 index 0000000000..a1b7c646fa --- /dev/null +++ b/config/testdata/local_disable_extra_scrape_metrics.good.yml @@ -0,0 +1,7 @@ +global: + extra_scrape_metrics: true +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['localhost:8080'] + extra_scrape_metrics: false diff --git a/config/testdata/local_enable_extra_scrape_metrics.good.yml b/config/testdata/local_enable_extra_scrape_metrics.good.yml new file mode 100644 index 0000000000..a1c8b2808e --- /dev/null +++ b/config/testdata/local_enable_extra_scrape_metrics.good.yml @@ -0,0 +1,7 @@ +global: + extra_scrape_metrics: false +scrape_configs: + - job_name: prometheus + static_configs: + - targets: ['localhost:8080'] + extra_scrape_metrics: true diff --git a/discovery/README.md b/discovery/README.md index d5418e7fb1..5d1adcf145 100644 --- a/discovery/README.md +++ b/discovery/README.md @@ -50,7 +50,7 @@ file for use with `file_sd`. The general principle with SD is to extract all the potentially useful information we can out of the SD, and let the user choose what they need of it using -[relabelling](https://prometheus.io/docs/operating/configuration/#). +[relabelling](https://prometheus.io/docs/operating/configuration/#relabel_config). This information is generally termed metadata. Metadata is exposed as a set of key/value pairs (labels) per target. The keys diff --git a/discovery/aws/aws.go b/discovery/aws/aws.go new file mode 100644 index 0000000000..f0f9c3d4df --- /dev/null +++ b/discovery/aws/aws.go @@ -0,0 +1,336 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "errors" + "fmt" + "time" + + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + + "github.com/prometheus/prometheus/discovery" +) + +// DefaultSDConfig is the default AWS SD configuration. +var DefaultSDConfig = SDConfig{ + RefreshInterval: model.Duration(60 * time.Second), + HTTPClientConfig: config.DefaultHTTPClientConfig, +} + +func init() { + discovery.RegisterConfig(&SDConfig{}) +} + +// Role is role of the service in AWS. +type Role string + +// The valid options for Role. +const ( + RoleEC2 Role = "ec2" + RoleECS Role = "ecs" + RoleElasticache Role = "elasticache" + RoleLightsail Role = "lightsail" + RoleMSK Role = "msk" +) + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *Role) UnmarshalYAML(unmarshal func(any) error) error { + if err := unmarshal((*string)(c)); err != nil { + return err + } + switch *c { + case RoleEC2, RoleECS, RoleElasticache, RoleLightsail, RoleMSK: + return nil + default: + return fmt.Errorf("unknown AWS SD role %q", *c) + } +} + +func (c Role) String() string { + return string(c) +} + +// SDConfig is the configuration for AWS service discovery. +type SDConfig struct { + Role Role `yaml:"role"` + Region string `yaml:"region,omitempty"` + Endpoint string `yaml:"endpoint,omitempty"` + AccessKey string `yaml:"access_key,omitempty"` + SecretKey config.Secret `yaml:"secret_key,omitempty"` + Profile string `yaml:"profile,omitempty"` + RoleARN string `yaml:"role_arn,omitempty"` + RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"` + Port int `yaml:"port,omitempty"` + HTTPClientConfig config.HTTPClientConfig `yaml:",inline"` + + // ec2 specific + Filters []*EC2Filter `yaml:"filters,omitempty"` + + // ecs, msk specific + Clusters []string `yaml:"clusters,omitempty"` + + // Embedded sub-configs (internal use only, not serialized) + *EC2SDConfig `yaml:"-"` + *ECSSDConfig `yaml:"-"` + *ElasticacheSDConfig `yaml:"-"` + *LightsailSDConfig `yaml:"-"` + *MSKSDConfig `yaml:"-"` +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for SDConfig. +func (c *SDConfig) UnmarshalYAML(unmarshal func(any) error) error { + // Alias to avoid recursion + type plain SDConfig + var aux plain + // Unmarshal into aux + if err := unmarshal(&aux); err != nil { + return err + } + *c = SDConfig(aux) + + var err error + c.Region, err = loadRegion(context.Background(), c.Region) + if err != nil { + return fmt.Errorf("could not determine AWS region: %w", err) + } + + switch c.Role { + case RoleEC2: + if c.EC2SDConfig == nil { + ec2Config := DefaultEC2SDConfig + c.EC2SDConfig = &ec2Config + } + c.EC2SDConfig.HTTPClientConfig = c.HTTPClientConfig + c.EC2SDConfig.Region = c.Region + if c.Endpoint != "" { + c.EC2SDConfig.Endpoint = c.Endpoint + } + if c.AccessKey != "" { + c.EC2SDConfig.AccessKey = c.AccessKey + } + if c.SecretKey != "" { + c.EC2SDConfig.SecretKey = c.SecretKey + } + if c.Profile != "" { + c.EC2SDConfig.Profile = c.Profile + } + if c.RoleARN != "" { + c.EC2SDConfig.RoleARN = c.RoleARN + } + if c.Port != 0 { + c.EC2SDConfig.Port = c.Port + } + if c.RefreshInterval != 0 { + c.EC2SDConfig.RefreshInterval = c.RefreshInterval + } + if c.Filters != nil { + c.EC2SDConfig.Filters = c.Filters + } + case RoleECS: + if c.ECSSDConfig == nil { + ecsConfig := DefaultECSSDConfig + c.ECSSDConfig = &ecsConfig + } + c.ECSSDConfig.HTTPClientConfig = c.HTTPClientConfig + c.ECSSDConfig.Region = c.Region + if c.Endpoint != "" { + c.ECSSDConfig.Endpoint = c.Endpoint + } + if c.AccessKey != "" { + c.ECSSDConfig.AccessKey = c.AccessKey + } + if c.SecretKey != "" { + c.ECSSDConfig.SecretKey = c.SecretKey + } + if c.Profile != "" { + c.ECSSDConfig.Profile = c.Profile + } + if c.RoleARN != "" { + c.ECSSDConfig.RoleARN = c.RoleARN + } + if c.Port != 0 { + c.ECSSDConfig.Port = c.Port + } + if c.RefreshInterval != 0 { + c.ECSSDConfig.RefreshInterval = c.RefreshInterval + } + if c.Clusters != nil { + c.ECSSDConfig.Clusters = c.Clusters + } + case RoleElasticache: + if c.ElasticacheSDConfig == nil { + elasticacheConfig := DefaultElasticacheSDConfig + c.ElasticacheSDConfig = &elasticacheConfig + } + c.ElasticacheSDConfig.HTTPClientConfig = c.HTTPClientConfig + c.ElasticacheSDConfig.Region = c.Region + if c.Endpoint != "" { + c.ElasticacheSDConfig.Endpoint = c.Endpoint + } + if c.AccessKey != "" { + c.ElasticacheSDConfig.AccessKey = c.AccessKey + } + if c.SecretKey != "" { + c.ElasticacheSDConfig.SecretKey = c.SecretKey + } + if c.Profile != "" { + c.ElasticacheSDConfig.Profile = c.Profile + } + if c.RoleARN != "" { + c.ElasticacheSDConfig.RoleARN = c.RoleARN + } + if c.Port != 0 { + c.ElasticacheSDConfig.Port = c.Port + } + if c.RefreshInterval != 0 { + c.ElasticacheSDConfig.RefreshInterval = c.RefreshInterval + } + if c.Clusters != nil { + c.ElasticacheSDConfig.Clusters = c.Clusters + } + case RoleLightsail: + if c.LightsailSDConfig == nil { + lightsailConfig := DefaultLightsailSDConfig + c.LightsailSDConfig = &lightsailConfig + } + c.LightsailSDConfig.HTTPClientConfig = c.HTTPClientConfig + c.LightsailSDConfig.Region = c.Region + if c.Endpoint != "" { + c.LightsailSDConfig.Endpoint = c.Endpoint + } + if c.AccessKey != "" { + c.LightsailSDConfig.AccessKey = c.AccessKey + } + if c.SecretKey != "" { + c.LightsailSDConfig.SecretKey = c.SecretKey + } + if c.Profile != "" { + c.LightsailSDConfig.Profile = c.Profile + } + if c.RoleARN != "" { + c.LightsailSDConfig.RoleARN = c.RoleARN + } + if c.Port != 0 { + c.LightsailSDConfig.Port = c.Port + } + if c.RefreshInterval != 0 { + c.LightsailSDConfig.RefreshInterval = c.RefreshInterval + } + case RoleMSK: + if c.MSKSDConfig == nil { + mskConfig := DefaultMSKSDConfig + c.MSKSDConfig = &mskConfig + } + c.MSKSDConfig.HTTPClientConfig = c.HTTPClientConfig + c.MSKSDConfig.Region = c.Region + if c.Endpoint != "" { + c.MSKSDConfig.Endpoint = c.Endpoint + } + if c.AccessKey != "" { + c.MSKSDConfig.AccessKey = c.AccessKey + } + if c.SecretKey != "" { + c.MSKSDConfig.SecretKey = c.SecretKey + } + if c.Profile != "" { + c.MSKSDConfig.Profile = c.Profile + } + if c.RoleARN != "" { + c.MSKSDConfig.RoleARN = c.RoleARN + } + if c.Port != 0 { + c.MSKSDConfig.Port = c.Port + } + if c.RefreshInterval != 0 { + c.MSKSDConfig.RefreshInterval = c.RefreshInterval + } + if c.Clusters != nil { + c.MSKSDConfig.Clusters = c.Clusters + } + default: + return fmt.Errorf("unknown AWS SD role %q", c.Role) + } + return nil +} + +// Name returns the name of the AWS Config. +func (*SDConfig) Name() string { return "aws" } + +// NewDiscovererMetrics implements discovery.Config. +func (*SDConfig) NewDiscovererMetrics(_ prometheus.Registerer, rmi discovery.RefreshMetricsInstantiator) discovery.DiscovererMetrics { + return &awsMetrics{refreshMetrics: rmi} +} + +// NewDiscoverer returns a Discoverer for the AWS Config. +func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + awsMetrics, ok := opts.Metrics.(*awsMetrics) + if !ok { + return nil, errors.New("invalid discovery metrics type for AWS SD") + } + + switch c.Role { + case RoleEC2: + opts.Metrics = &ec2Metrics{refreshMetrics: awsMetrics.refreshMetrics} + return NewEC2Discovery(c.EC2SDConfig, opts) + case RoleECS: + opts.Metrics = &ecsMetrics{refreshMetrics: awsMetrics.refreshMetrics} + return NewECSDiscovery(c.ECSSDConfig, opts) + case RoleElasticache: + opts.Metrics = &elasticacheMetrics{refreshMetrics: awsMetrics.refreshMetrics} + return NewElasticacheDiscovery(c.ElasticacheSDConfig, opts) + case RoleLightsail: + opts.Metrics = &lightsailMetrics{refreshMetrics: awsMetrics.refreshMetrics} + return NewLightsailDiscovery(c.LightsailSDConfig, opts) + case RoleMSK: + opts.Metrics = &mskMetrics{refreshMetrics: awsMetrics.refreshMetrics} + return NewMSKDiscovery(c.MSKSDConfig, opts) + default: + return nil, fmt.Errorf("unknown AWS SD role %q", c.Role) + } +} + +// loadRegion finds the region in order: AWS config/env vars ->IMDS. +func loadRegion(ctx context.Context, specifiedRegion string) (string, error) { + if specifiedRegion != "" { + return specifiedRegion, nil + } + + cfg, err := awsConfig.LoadDefaultConfig(ctx) + if err != nil { + return "", fmt.Errorf("failed to load AWS config: %w", err) + } + + if cfg.Region != "" { + return cfg.Region, nil + } + + // Fallback (may fail in non-AWS environments) + imdsClient := imds.NewFromConfig(cfg) + region, err := imdsClient.GetRegion(ctx, &imds.GetRegionInput{}) + if err != nil { + return "", fmt.Errorf("failed to get region from IMDS: %w", err) + } + + if region.Region == "" { + return "", errors.New("region not found in AWS config or IMDS") + } + + return region.Region, nil +} diff --git a/discovery/aws/aws_test.go b/discovery/aws/aws_test.go new file mode 100644 index 0000000000..d1ec7b2282 --- /dev/null +++ b/discovery/aws/aws_test.go @@ -0,0 +1,489 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "errors" + "math/rand/v2" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +func TestRoleUnmarshalYAML(t *testing.T) { + tests := []struct { + name string + input string + expected Role + wantErr bool + }{ + { + name: "EC2Role", + input: "ec2", + expected: RoleEC2, + wantErr: false, + }, + { + name: "LightsailRole", + input: "lightsail", + expected: RoleLightsail, + wantErr: false, + }, + { + name: "ECSRole", + input: "ecs", + expected: RoleECS, + wantErr: false, + }, + { + name: "InvalidRole", + input: "invalid", + expected: "invalid", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r Role + err := r.UnmarshalYAML(func(v any) error { + ptr, ok := v.(*string) + if !ok { + return errors.New("not a string pointer") + } + *ptr = tt.input + return nil + }) + if tt.wantErr { + require.Error(t, err, "expected error for input %q", tt.input) + } else { + require.NoError(t, err, "unexpected error for input %q", tt.input) + require.Equal(t, tt.expected, r, "unexpected role for input %q", tt.input) + } + }) + } +} + +func TestRoleString(t *testing.T) { + tests := []struct { + name string + role Role + expected string + }{ + { + name: "EC2", + role: RoleEC2, + expected: "ec2", + }, + { + name: "Lightsail", + role: RoleLightsail, + expected: "lightsail", + }, + { + name: "ECS", + role: RoleECS, + expected: "ecs", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, tt.role.String()) + }) + } +} + +func TestSDConfigName(t *testing.T) { + cfg := &SDConfig{} + require.Equal(t, "aws", cfg.Name()) +} + +func TestDefaultSDConfig(t *testing.T) { + require.Equal(t, Role(""), DefaultSDConfig.Role) + require.Equal(t, model.Duration(60*time.Second), DefaultSDConfig.RefreshInterval) +} + +func TestSDConfigUnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + validateFunc func(t *testing.T, cfg *SDConfig) + }{ + { + name: "EC2WithFlatFields", + yaml: `role: ec2 +region: us-west-2 +port: 9100 +filters: + - name: instance-state-name + values: [running]`, + validateFunc: func(t *testing.T, cfg *SDConfig) { + require.Equal(t, RoleEC2, cfg.Role) + require.NotNil(t, cfg.EC2SDConfig) + require.Equal(t, "us-west-2", cfg.EC2SDConfig.Region) + require.Equal(t, 9100, cfg.EC2SDConfig.Port) + require.Len(t, cfg.EC2SDConfig.Filters, 1) + require.Equal(t, "instance-state-name", cfg.EC2SDConfig.Filters[0].Name) + require.Equal(t, []string{"running"}, cfg.EC2SDConfig.Filters[0].Values) + }, + }, + { + name: "ECSWithFlatFields", + yaml: `role: ecs +region: us-east-1 +port: 9200 +clusters: ["some-cluster"]`, + validateFunc: func(t *testing.T, cfg *SDConfig) { + require.Equal(t, RoleECS, cfg.Role) + require.NotNil(t, cfg.ECSSDConfig) + require.Equal(t, "us-east-1", cfg.ECSSDConfig.Region) + require.Equal(t, 9200, cfg.ECSSDConfig.Port) + require.Equal(t, []string{"some-cluster"}, cfg.ECSSDConfig.Clusters) + }, + }, + { + name: "LightsailWithFlatFields", + yaml: `role: lightsail +region: eu-central-1 +port: 9300`, + validateFunc: func(t *testing.T, cfg *SDConfig) { + require.Equal(t, RoleLightsail, cfg.Role) + require.NotNil(t, cfg.LightsailSDConfig) + require.Equal(t, "eu-central-1", cfg.LightsailSDConfig.Region) + require.Equal(t, 9300, cfg.LightsailSDConfig.Port) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cfg SDConfig + require.NoError(t, yaml.Unmarshal([]byte(tt.yaml), &cfg)) + tt.validateFunc(t, &cfg) + }) + } +} + +// TestMultipleSDConfigsDoNotShareState verifies that multiple AWS SD configs +// don't share the same underlying configuration object. This was a bug where +// all configs pointed to the same global default, causing port and other +// settings from one job to overwrite settings in another job. +func TestMultipleSDConfigsDoNotShareState(t *testing.T) { + tests := []struct { + name string + yaml string + validateFunc func(t *testing.T, cfg1, cfg2 *SDConfig) + }{ + { + name: "EC2MultipleJobsDifferentPorts", + yaml: ` +- role: ec2 + region: us-west-2 + port: 9100 + filters: + - name: tag:Name + values: [host-1] +- role: ec2 + region: us-west-2 + port: 9101 + filters: + - name: tag:Name + values: [host-2]`, + validateFunc: func(t *testing.T, cfg1, cfg2 *SDConfig) { + require.Equal(t, RoleEC2, cfg1.Role) + require.Equal(t, RoleEC2, cfg2.Role) + require.NotNil(t, cfg1.EC2SDConfig) + require.NotNil(t, cfg2.EC2SDConfig) + + // Verify ports are different and not shared + require.Equal(t, 9100, cfg1.EC2SDConfig.Port) + require.Equal(t, 9101, cfg2.EC2SDConfig.Port) + + // Verify filters are different and not shared + require.Len(t, cfg1.EC2SDConfig.Filters, 1) + require.Len(t, cfg2.EC2SDConfig.Filters, 1) + require.Equal(t, []string{"host-1"}, cfg1.EC2SDConfig.Filters[0].Values) + require.Equal(t, []string{"host-2"}, cfg2.EC2SDConfig.Filters[0].Values) + + // Most importantly: verify they're not the same pointer + require.NotSame(t, cfg1.EC2SDConfig, cfg2.EC2SDConfig, + "EC2SDConfig objects should not share the same memory address") + }, + }, + { + name: "ECSMultipleJobsDifferentPorts", + yaml: ` +- role: ecs + region: us-east-1 + port: 8080 + clusters: [cluster-a] +- role: ecs + region: us-east-1 + port: 8081 + clusters: [cluster-b]`, + validateFunc: func(t *testing.T, cfg1, cfg2 *SDConfig) { + require.Equal(t, RoleECS, cfg1.Role) + require.Equal(t, RoleECS, cfg2.Role) + require.NotNil(t, cfg1.ECSSDConfig) + require.NotNil(t, cfg2.ECSSDConfig) + + require.Equal(t, 8080, cfg1.ECSSDConfig.Port) + require.Equal(t, 8081, cfg2.ECSSDConfig.Port) + require.Equal(t, []string{"cluster-a"}, cfg1.ECSSDConfig.Clusters) + require.Equal(t, []string{"cluster-b"}, cfg2.ECSSDConfig.Clusters) + + require.NotSame(t, cfg1.ECSSDConfig, cfg2.ECSSDConfig, + "ECSSDConfig objects should not share the same memory address") + }, + }, + { + name: "LightsailMultipleJobsDifferentPorts", + yaml: ` +- role: lightsail + region: eu-west-1 + port: 7070 +- role: lightsail + region: eu-west-1 + port: 7071`, + validateFunc: func(t *testing.T, cfg1, cfg2 *SDConfig) { + require.Equal(t, RoleLightsail, cfg1.Role) + require.Equal(t, RoleLightsail, cfg2.Role) + require.NotNil(t, cfg1.LightsailSDConfig) + require.NotNil(t, cfg2.LightsailSDConfig) + + require.Equal(t, 7070, cfg1.LightsailSDConfig.Port) + require.Equal(t, 7071, cfg2.LightsailSDConfig.Port) + + require.NotSame(t, cfg1.LightsailSDConfig, cfg2.LightsailSDConfig, + "LightsailSDConfig objects should not share the same memory address") + }, + }, + { + name: "MSKMultipleJobsDifferentPorts", + yaml: ` +- role: msk + region: ap-south-1 + port: 6060 + clusters: ["cluster-1"] +- role: msk + region: ap-south-1 + port: 6061 + clusters: ["cluster-2"]`, + validateFunc: func(t *testing.T, cfg1, cfg2 *SDConfig) { + require.Equal(t, RoleMSK, cfg1.Role) + require.Equal(t, RoleMSK, cfg2.Role) + require.NotNil(t, cfg1.MSKSDConfig) + require.NotNil(t, cfg2.MSKSDConfig) + + require.Equal(t, 6060, cfg1.MSKSDConfig.Port) + require.Equal(t, []string{"cluster-1"}, cfg1.MSKSDConfig.Clusters) + require.Equal(t, 6061, cfg2.MSKSDConfig.Port) + require.Equal(t, []string{"cluster-2"}, cfg2.MSKSDConfig.Clusters) + + require.NotSame(t, cfg1.MSKSDConfig, cfg2.MSKSDConfig, + "MSKSDConfig objects should not share the same memory address") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var configs []SDConfig + require.NoError(t, yaml.Unmarshal([]byte(tt.yaml), &configs)) + require.Len(t, configs, 2) + tt.validateFunc(t, &configs[0], &configs[1]) + }) + } +} + +// getRandomRegion is a helper to return a pseudo-random AWS region for testing. +func getRandomRegion() string { + regions := []string{ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "eu-west-1", + "eu-west-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-northeast-1", + "ap-northeast-2", + } + + return regions[rand.IntN(len(regions))] +} + +func TestLoadRegion(t *testing.T) { + t.Run("with_env_region", func(t *testing.T) { + randomRegion := getRandomRegion() + t.Setenv("AWS_REGION", randomRegion) + t.Setenv("AWS_ACCESS_KEY_ID", "dummy") + t.Setenv("AWS_SECRET_ACCESS_KEY", "dummy") + t.Setenv("AWS_CONFIG_FILE", "") // Ensure no config file is used + t.Setenv("AWS_PROFILE", "") // Ensure no profile file is used + + region, err := loadRegion(context.Background(), "") + require.NoError(t, err) + require.Equal(t, randomRegion, region) + }) + + t.Run("with_config_file_default_profile", func(t *testing.T) { + randomRegion := getRandomRegion() + + // Create a temporary AWS config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = ` + randomRegion + ` +` + + err := os.WriteFile(configFile, []byte(configContent), 0o644) + require.NoError(t, err) + defer os.Remove(configFile) + + // Set up environment to use the config file + t.Setenv("AWS_CONFIG_FILE", configFile) + t.Setenv("AWS_ACCESS_KEY_ID", "dummy") + t.Setenv("AWS_SECRET_ACCESS_KEY", "dummy") + // Clear any region environment variables to force config file usage + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_PROFILE", "") // Ensure no profile file is used + t.Setenv("AWS_DEFAULT_REGION", "") + + region, err := loadRegion(context.Background(), "") + require.NoError(t, err) + require.Equal(t, randomRegion, region) + }) + + t.Run("with_config_file_named_profile", func(t *testing.T) { + randomRegion := getRandomRegion() + + // Create a temporary AWS config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config") + + configContent := `[default] +region = ` + getRandomRegion() + ` + +[profile ` + randomRegion + `-profile] +region = ` + randomRegion + ` +` + + err := os.WriteFile(configFile, []byte(configContent), 0o644) + require.NoError(t, err) + defer os.Remove(configFile) + + // Set up environment to use the config file + t.Setenv("AWS_CONFIG_FILE", configFile) + t.Setenv("AWS_PROFILE", randomRegion+"-profile") + t.Setenv("AWS_ACCESS_KEY_ID", "dummy") + t.Setenv("AWS_SECRET_ACCESS_KEY", "dummy") + // Clear any region environment variables to force config file usage + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_DEFAULT_REGION", "") + + region, err := loadRegion(context.Background(), "") + require.NoError(t, err) + require.Equal(t, randomRegion, region) + }) + + t.Run("with_specified_region", func(t *testing.T) { + specifiedRegion := getRandomRegion() + + // Even with environment region set differently, specified region should take precedence + t.Setenv("AWS_REGION", getRandomRegion()) + t.Setenv("AWS_ACCESS_KEY_ID", "dummy") + t.Setenv("AWS_SECRET_ACCESS_KEY", "dummy") + + region, err := loadRegion(context.Background(), specifiedRegion) + require.NoError(t, err) + require.Equal(t, specifiedRegion, region) + }) + + t.Run("imds_fallback", func(t *testing.T) { + randomRegion := getRandomRegion() + + // Mock IMDS server that returns a region + mockIMDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle instance identity document (contains region info) + if r.URL.Path == "/latest/dynamic/instance-identity/document" { + imdsPayload := `{"region": "` + randomRegion + `"}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(imdsPayload)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockIMDS.Close() + + // Set up environment with no region but valid credentials + // This will force fallback to IMDS + t.Setenv("AWS_ACCESS_KEY_ID", "dummy") + t.Setenv("AWS_SECRET_ACCESS_KEY", "dummy") + // Unset any existing region + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_DEFAULT_REGION", "") + t.Setenv("AWS_CONFIG_FILE", "") // Ensure no config file is used + t.Setenv("AWS_PROFILE", "") // Ensure no profile file is used + // Point IMDS to our mock server + t.Setenv("AWS_EC2_METADATA_SERVICE_ENDPOINT", mockIMDS.URL) + + region, err := loadRegion(context.Background(), "") + require.NoError(t, err) + require.Equal(t, randomRegion, region) + }) + + t.Run("imds_empty_region", func(t *testing.T) { + // Mock IMDS server that returns empty region + mockIMDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle instance identity document with empty region + if r.URL.Path == "/latest/dynamic/instance-identity/document" { + imdsPayload := `{"region": ""}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(imdsPayload)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer mockIMDS.Close() + + // Set up environment with no region but valid credentials + t.Setenv("AWS_ACCESS_KEY_ID", "dummy") + t.Setenv("AWS_SECRET_ACCESS_KEY", "dummy") + // Unset any existing region + t.Setenv("AWS_REGION", "") + t.Setenv("AWS_DEFAULT_REGION", "") + t.Setenv("AWS_CONFIG_FILE", "") // Ensure no config file is used + t.Setenv("AWS_PROFILE", "") // Ensure no profile file is used + // Point IMDS to our mock server + t.Setenv("AWS_EC2_METADATA_SERVICE_ENDPOINT", mockIMDS.URL) + + _, err := loadRegion(context.Background(), "") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to get region from IMDS") + }) +} diff --git a/discovery/aws/ec2.go b/discovery/aws/ec2.go index 0386d9bb1c..48ab411d72 100644 --- a/discovery/aws/ec2.go +++ b/discovery/aws/ec2.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -27,7 +27,6 @@ import ( awsConfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" - "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -113,7 +112,7 @@ func (*EC2SDConfig) Name() string { return "ec2" } // NewDiscoverer returns a Discoverer for the EC2 Config. func (c *EC2SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewEC2Discovery(c, opts.Logger, opts.Metrics) + return NewEC2Discovery(c, opts) } // UnmarshalYAML implements the yaml.Unmarshaler interface for the EC2 Config. @@ -125,31 +124,10 @@ func (c *EC2SDConfig) UnmarshalYAML(unmarshal func(any) error) error { return err } - if c.Region == "" { - cfg, err := awsConfig.LoadDefaultConfig(context.Background()) - if err != nil { - return err - } - - if cfg.Region != "" { - // If the region is already set in the config, use it. - // This can happen if the user has set the region in the AWS config file or environment variables. - c.Region = cfg.Region - } - - if c.Region == "" { - // Try to get the region from the instance metadata service (IMDS). - imdsClient := imds.NewFromConfig(cfg) - region, err := imdsClient.GetRegion(context.Background(), &imds.GetRegionInput{}) - if err != nil { - return err - } - c.Region = region.Region - } - } - - if c.Region == "" { - return errors.New("EC2 SD configuration requires a region") + // Check if the region is set, if not attempt to load it from the AWS SDK. + c.Region, err = loadRegion(context.Background(), c.Region) + if err != nil { + return fmt.Errorf("could not determine AWS region: %w", err) } for _, f := range c.Filters { @@ -180,23 +158,24 @@ type EC2Discovery struct { } // NewEC2Discovery returns a new EC2Discovery which periodically refreshes its targets. -func NewEC2Discovery(conf *EC2SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*EC2Discovery, error) { - m, ok := metrics.(*ec2Metrics) +func NewEC2Discovery(conf *EC2SDConfig, opts discovery.DiscovererOptions) (*EC2Discovery, error) { + m, ok := opts.Metrics.(*ec2Metrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - if logger == nil { - logger = promslog.NewNopLogger() + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() } d := &EC2Discovery{ - logger: logger, + logger: opts.Logger, cfg: conf, } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "ec2", + SetName: opts.SetName, Interval: time.Duration(d.cfg.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, @@ -245,7 +224,12 @@ func (d *EC2Discovery) ec2Client(ctx context.Context) (ec2Client, error) { cfg.Credentials = aws.NewCredentialsCache(assumeProvider) } - d.ec2 = ec2.NewFromConfig(cfg) + d.ec2 = ec2.NewFromConfig(cfg, func(options *ec2.Options) { + if d.cfg.Endpoint != "" { + options.BaseEndpoint = &d.cfg.Endpoint + } + options.HTTPClient = httpClient + }) return d.ec2, nil } @@ -255,8 +239,15 @@ func (d *EC2Discovery) refreshAZIDs(ctx context.Context) error { if err != nil { return err } + if azs.AvailabilityZones == nil { + d.azToAZID = make(map[string]string) + return nil + } d.azToAZID = make(map[string]string, len(azs.AvailabilityZones)) for _, az := range azs.AvailabilityZones { + if az.ZoneName == nil || az.ZoneId == nil { + continue + } d.azToAZID[*az.ZoneName] = *az.ZoneId } return nil diff --git a/discovery/aws/ec2_test.go b/discovery/aws/ec2_test.go index 46ab8e771d..bd1047ffc0 100644 --- a/discovery/aws/ec2_test.go +++ b/discovery/aws/ec2_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/aws/ecs.go b/discovery/aws/ecs.go new file mode 100644 index 0000000000..18d2746cb6 --- /dev/null +++ b/discovery/aws/ecs.go @@ -0,0 +1,995 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ecs" + "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/prometheus/common/promslog" + "golang.org/x/sync/errgroup" + + "github.com/prometheus/prometheus/discovery" + "github.com/prometheus/prometheus/discovery/refresh" + "github.com/prometheus/prometheus/discovery/targetgroup" + "github.com/prometheus/prometheus/util/strutil" +) + +const ( + ecsLabel = model.MetaLabelPrefix + "ecs_" + ecsLabelCluster = ecsLabel + "cluster" + ecsLabelClusterARN = ecsLabel + "cluster_arn" + ecsLabelService = ecsLabel + "service" + ecsLabelServiceARN = ecsLabel + "service_arn" + ecsLabelServiceStatus = ecsLabel + "service_status" + ecsLabelTaskGroup = ecsLabel + "task_group" + ecsLabelTaskARN = ecsLabel + "task_arn" + ecsLabelTaskDefinition = ecsLabel + "task_definition" + ecsLabelRegion = ecsLabel + "region" + ecsLabelAvailabilityZone = ecsLabel + "availability_zone" + ecsLabelSubnetID = ecsLabel + "subnet_id" + ecsLabelIPAddress = ecsLabel + "ip_address" + ecsLabelLaunchType = ecsLabel + "launch_type" + ecsLabelDesiredStatus = ecsLabel + "desired_status" + ecsLabelLastStatus = ecsLabel + "last_status" + ecsLabelHealthStatus = ecsLabel + "health_status" + ecsLabelPlatformFamily = ecsLabel + "platform_family" + ecsLabelPlatformVersion = ecsLabel + "platform_version" + ecsLabelTag = ecsLabel + "tag_" + ecsLabelTagCluster = ecsLabelTag + "cluster_" + ecsLabelTagService = ecsLabelTag + "service_" + ecsLabelTagTask = ecsLabelTag + "task_" + ecsLabelTagEC2 = ecsLabelTag + "ec2_" + ecsLabelNetworkMode = ecsLabel + "network_mode" + ecsLabelContainerInstanceARN = ecsLabel + "container_instance_arn" + ecsLabelEC2InstanceID = ecsLabel + "ec2_instance_id" + ecsLabelEC2InstanceType = ecsLabel + "ec2_instance_type" + ecsLabelEC2InstancePrivateIP = ecsLabel + "ec2_instance_private_ip" + ecsLabelEC2InstancePublicIP = ecsLabel + "ec2_instance_public_ip" + ecsLabelPublicIP = ecsLabel + "public_ip" +) + +// DefaultECSSDConfig is the default ECS SD configuration. +var DefaultECSSDConfig = ECSSDConfig{ + Port: 80, + RefreshInterval: model.Duration(60 * time.Second), + RequestConcurrency: 20, // Aligned with AWS ECS API sustained rate limits (20 req/sec) + HTTPClientConfig: config.DefaultHTTPClientConfig, +} + +func init() { + discovery.RegisterConfig(&ECSSDConfig{}) +} + +// ECSSDConfig is the configuration for ECS based service discovery. +type ECSSDConfig struct { + Region string `yaml:"region"` + Endpoint string `yaml:"endpoint"` + AccessKey string `yaml:"access_key,omitempty"` + SecretKey config.Secret `yaml:"secret_key,omitempty"` + Profile string `yaml:"profile,omitempty"` + RoleARN string `yaml:"role_arn,omitempty"` + Clusters []string `yaml:"clusters,omitempty"` + Port int `yaml:"port"` + RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"` + + // RequestConcurrency controls the maximum number of concurrent ECS API requests. + // Default is 20, which aligns with AWS ECS sustained rate limits: + // - Cluster read actions (DescribeClusters, ListClusters): 20 req/sec sustained + // - Service read actions (DescribeServices, ListServices): 20 req/sec sustained + // - Cluster resource read actions (DescribeTasks, ListTasks): 20 req/sec sustained + // See: https://docs.aws.amazon.com/AmazonECS/latest/APIReference/request-throttling.html + RequestConcurrency int `yaml:"request_concurrency,omitempty"` + + HTTPClientConfig config.HTTPClientConfig `yaml:",inline"` +} + +// NewDiscovererMetrics implements discovery.Config. +func (*ECSSDConfig) NewDiscovererMetrics(_ prometheus.Registerer, rmi discovery.RefreshMetricsInstantiator) discovery.DiscovererMetrics { + return &ecsMetrics{ + refreshMetrics: rmi, + } +} + +// Name returns the name of the ECS Config. +func (*ECSSDConfig) Name() string { return "ecs" } + +// NewDiscoverer returns a Discoverer for the EC2 Config. +func (c *ECSSDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewECSDiscovery(c, opts) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for the ECS Config. +func (c *ECSSDConfig) UnmarshalYAML(unmarshal func(any) error) error { + *c = DefaultECSSDConfig + type plain ECSSDConfig + err := unmarshal((*plain)(c)) + if err != nil { + return err + } + + c.Region, err = loadRegion(context.Background(), c.Region) + if err != nil { + return fmt.Errorf("could not determine AWS region: %w", err) + } + + return c.HTTPClientConfig.Validate() +} + +type ecsClient interface { + ListClusters(context.Context, *ecs.ListClustersInput, ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) + DescribeClusters(context.Context, *ecs.DescribeClustersInput, ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) + ListServices(context.Context, *ecs.ListServicesInput, ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) + DescribeServices(context.Context, *ecs.DescribeServicesInput, ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) + ListTasks(context.Context, *ecs.ListTasksInput, ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) + DescribeTasks(context.Context, *ecs.DescribeTasksInput, ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) + DescribeContainerInstances(context.Context, *ecs.DescribeContainerInstancesInput, ...func(*ecs.Options)) (*ecs.DescribeContainerInstancesOutput, error) +} + +type ecsEC2Client interface { + DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) + DescribeNetworkInterfaces(context.Context, *ec2.DescribeNetworkInterfacesInput, ...func(*ec2.Options)) (*ec2.DescribeNetworkInterfacesOutput, error) +} + +// ECSDiscovery periodically performs ECS-SD requests. It implements +// the Discoverer interface. +type ECSDiscovery struct { + *refresh.Discovery + logger *slog.Logger + cfg *ECSSDConfig + ecs ecsClient + ec2 ecsEC2Client +} + +// NewECSDiscovery returns a new ECSDiscovery which periodically refreshes its targets. +func NewECSDiscovery(conf *ECSSDConfig, opts discovery.DiscovererOptions) (*ECSDiscovery, error) { + m, ok := opts.Metrics.(*ecsMetrics) + if !ok { + return nil, errors.New("invalid discovery metrics type") + } + + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() + } + d := &ECSDiscovery{ + logger: opts.Logger, + cfg: conf, + } + d.Discovery = refresh.NewDiscovery( + refresh.Options{ + Logger: opts.Logger, + Mech: "ecs", + Interval: time.Duration(d.cfg.RefreshInterval), + RefreshF: d.refresh, + MetricsInstantiator: m.refreshMetrics, + }, + ) + return d, nil +} + +func (d *ECSDiscovery) initEcsClient(ctx context.Context) error { + if d.ecs != nil && d.ec2 != nil { + return nil + } + + if d.cfg.Region == "" { + return errors.New("region must be set for ECS service discovery") + } + + // Build the HTTP client from the provided HTTPClientConfig. + client, err := config.NewClientFromConfig(d.cfg.HTTPClientConfig, "ecs_sd") + if err != nil { + return err + } + + // Build the AWS config with the provided region. + var configOptions []func(*awsConfig.LoadOptions) error + configOptions = append(configOptions, awsConfig.WithRegion(d.cfg.Region)) + configOptions = append(configOptions, awsConfig.WithHTTPClient(client)) + + // Only set static credentials if both access key and secret key are provided + // Otherwise, let AWS SDK use its default credential chain + if d.cfg.AccessKey != "" && d.cfg.SecretKey != "" { + credProvider := credentials.NewStaticCredentialsProvider(d.cfg.AccessKey, string(d.cfg.SecretKey), "") + configOptions = append(configOptions, awsConfig.WithCredentialsProvider(credProvider)) + } + + if d.cfg.Profile != "" { + configOptions = append(configOptions, awsConfig.WithSharedConfigProfile(d.cfg.Profile)) + } + + cfg, err := awsConfig.LoadDefaultConfig(ctx, configOptions...) + if err != nil { + d.logger.Error("Failed to create AWS config", "error", err) + return fmt.Errorf("could not create aws config: %w", err) + } + + // If the role ARN is set, assume the role to get credentials and set the credentials provider in the config. + if d.cfg.RoleARN != "" { + assumeProvider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), d.cfg.RoleARN) + cfg.Credentials = aws.NewCredentialsCache(assumeProvider) + } + + d.ecs = ecs.NewFromConfig(cfg, func(options *ecs.Options) { + if d.cfg.Endpoint != "" { + options.BaseEndpoint = &d.cfg.Endpoint + } + options.HTTPClient = client + }) + + d.ec2 = ec2.NewFromConfig(cfg, func(options *ec2.Options) { + options.HTTPClient = client + }) + + // Test credentials by making a simple API call + testCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + _, err = d.ecs.DescribeClusters(testCtx, &ecs.DescribeClustersInput{}) + if err != nil { + d.logger.Error("Failed to test ECS credentials", "error", err) + return fmt.Errorf("ECS credential test failed: %w", err) + } + + return nil +} + +// listClusterARNs returns a slice of cluster arns. +// This method does not use concurrency as it's a simple paginated call. +func (d *ECSDiscovery) listClusterARNs(ctx context.Context) ([]string, error) { + var ( + clusterARNs []string + nextToken *string + ) + for { + resp, err := d.ecs.ListClusters(ctx, &ecs.ListClustersInput{ + NextToken: nextToken, + MaxResults: aws.Int32(100), + }) + if err != nil { + return nil, fmt.Errorf("could not list clusters: %w", err) + } + + clusterARNs = append(clusterARNs, resp.ClusterArns...) + + if resp.NextToken == nil { + break + } + nextToken = resp.NextToken + } + + return clusterARNs, nil +} + +// describeClusters returns a map of cluster ARN to a slice of clusters. +// Uses concurrent requests limited by RequestConcurrency to respect AWS API throttling. +// Clusters are described in batches of 100 to respect AWS API limits (DescribeClusters allows up to 100 clusters per call). +func (d *ECSDiscovery) describeClusters(ctx context.Context, clusters []string) (map[string]types.Cluster, error) { + mu := sync.Mutex{} + clusterMap := make(map[string]types.Cluster) + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for batch := range slices.Chunk(clusters, 100) { + errg.Go(func() error { + resp, err := d.ecs.DescribeClusters(ectx, &ecs.DescribeClustersInput{ + Clusters: batch, + Include: []types.ClusterField{"TAGS"}, + }) + if err != nil { + d.logger.Error("Failed to describe clusters", "clusters", batch, "error", err) + return fmt.Errorf("could not describe clusters %v: %w", batch, err) + } + + for _, cluster := range resp.Clusters { + if cluster.ClusterArn != nil { + mu.Lock() + clusterMap[*cluster.ClusterArn] = cluster + mu.Unlock() + } + } + return nil + }) + } + + return clusterMap, errg.Wait() +} + +// listServiceARNs returns a map of cluster ARN to a slice of service ARNs. +// Uses concurrent requests limited by RequestConcurrency to respect AWS API throttling. +// Services are listed in batches of 100 to respect AWS API limits (ListServices allows up to 100 services per call). +func (d *ECSDiscovery) listServiceARNs(ctx context.Context, clusters []string) (map[string][]string, error) { + mu := sync.Mutex{} + services := make(map[string][]string) + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for _, clusterARN := range clusters { + errg.Go(func() error { + var nextToken *string + var serviceARNs []string + for { + resp, err := d.ecs.ListServices(ectx, &ecs.ListServicesInput{ + Cluster: aws.String(clusterARN), + NextToken: nextToken, + MaxResults: aws.Int32(100), + }) + if err != nil { + return fmt.Errorf("could not list services for cluster %q: %w", clusterARN, err) + } + + serviceARNs = append(serviceARNs, resp.ServiceArns...) + + if resp.NextToken == nil { + break + } + nextToken = resp.NextToken + } + + mu.Lock() + services[clusterARN] = serviceARNs + mu.Unlock() + return nil + }) + } + + return services, errg.Wait() +} + +// describeServices returns a map of service name to service. +// Uses concurrent requests limited by RequestConcurrency to respect AWS API throttling. +// Services are described in batches of 10 to respect AWS API limits (DescribeServices allows up to 10 services per call). +func (d *ECSDiscovery) describeServices(ctx context.Context, clusterARN string, serviceARNS []string) (map[string]types.Service, error) { + mu := sync.Mutex{} + services := make(map[string]types.Service) + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for batch := range slices.Chunk(serviceARNS, 10) { + errg.Go(func() error { + resp, err := d.ecs.DescribeServices(ectx, &ecs.DescribeServicesInput{ + Cluster: aws.String(clusterARN), + Services: batch, + Include: []types.ServiceField{"TAGS"}, + }) + if err != nil { + d.logger.Error("Failed to describe services", "cluster", clusterARN, "batch", batch, "error", err) + return fmt.Errorf("could not describe services for cluster %q: batch %v: %w", clusterARN, batch, err) + } + + for _, service := range resp.Services { + if service.ServiceArn != nil { + mu.Lock() + services[*service.ServiceName] = service + mu.Unlock() + } + } + return nil + }) + } + + return services, errg.Wait() +} + +// listTaskARNs returns a map of clustersARN to a slice of task ARNs. +// Uses concurrent requests limited by RequestConcurrency to respect AWS API throttling. +// Tasks are listed in batches of 100 to respect AWS API limits (ListTasks allows up to 100 tasks per call). +// This method also uses pagination to handle cases where there are more than 100 tasks in a cluster. +func (d *ECSDiscovery) listTaskARNs(ctx context.Context, clusterARNs []string) (map[string][]string, error) { + mu := sync.Mutex{} + tasks := make(map[string][]string) + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for _, clusterARN := range clusterARNs { + errg.Go(func() error { + var ( + nextToken *string + taskARNs []string + ) + for { + resp, err := d.ecs.ListTasks(ectx, &ecs.ListTasksInput{ + Cluster: aws.String(clusterARN), + NextToken: nextToken, + MaxResults: aws.Int32(100), + }) + if err != nil { + return fmt.Errorf("could not list tasks for cluster %q: %w", clusterARN, err) + } + + taskARNs = append(taskARNs, resp.TaskArns...) + + if resp.NextToken == nil { + break + } + nextToken = resp.NextToken + } + + mu.Lock() + tasks[clusterARN] = taskARNs + mu.Unlock() + return nil + }) + } + + return tasks, errg.Wait() +} + +// describeTasks returns a slice of tasks. +// Uses concurrent requests limited by RequestConcurrency to respect AWS API throttling. +// Tasks are described in batches of 100 to respect AWS API limits (DescribeTasks allows up to 100 tasks per call). +func (d *ECSDiscovery) describeTasks(ctx context.Context, clusterARN string, taskARNs []string) ([]types.Task, error) { + mu := sync.Mutex{} + var tasks []types.Task + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for batch := range slices.Chunk(taskARNs, 100) { + errg.Go(func() error { + resp, err := d.ecs.DescribeTasks(ectx, &ecs.DescribeTasksInput{ + Cluster: aws.String(clusterARN), + Tasks: batch, + Include: []types.TaskField{"TAGS"}, + }) + if err != nil { + d.logger.Error("Failed to describe tasks", "cluster", clusterARN, "batch", batch, "error", err) + return fmt.Errorf("could not describe tasks in cluster %q: batch %v: %w", clusterARN, batch, err) + } + + mu.Lock() + tasks = append(tasks, resp.Tasks...) + mu.Unlock() + return nil + }) + } + + return tasks, errg.Wait() +} + +// describeContainerInstances returns a map of container instance ARN to EC2 instance ID +// Uses concurrent requests limited by RequestConcurrency to respect AWS API throttling. +// Container instances are described in batches of 100 to respect AWS API limits (DescribeContainerInstances allows up to 100 container instances per call). +func (d *ECSDiscovery) describeContainerInstances(ctx context.Context, clusterARN string, tasks []types.Task) (map[string]string, error) { + containerInstanceARNs := make([]string, 0, len(tasks)) + for _, task := range tasks { + if task.ContainerInstanceArn != nil { + containerInstanceARNs = append(containerInstanceARNs, *task.ContainerInstanceArn) + } + } + + if len(containerInstanceARNs) == 0 { + return make(map[string]string), nil + } + + mu := sync.Mutex{} + containerInstToEC2 := make(map[string]string) + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for batch := range slices.Chunk(containerInstanceARNs, 100) { + errg.Go(func() error { + resp, err := d.ecs.DescribeContainerInstances(ectx, &ecs.DescribeContainerInstancesInput{ + Cluster: aws.String(clusterARN), + ContainerInstances: batch, + }) + if err != nil { + return fmt.Errorf("could not describe container instances: %w", err) + } + + for _, ci := range resp.ContainerInstances { + if ci.ContainerInstanceArn != nil && ci.Ec2InstanceId != nil { + mu.Lock() + containerInstToEC2[*ci.ContainerInstanceArn] = *ci.Ec2InstanceId + mu.Unlock() + } + } + return nil + }) + } + + return containerInstToEC2, errg.Wait() +} + +// ec2InstanceInfo holds information retrieved from EC2 DescribeInstances. +type ec2InstanceInfo struct { + privateIP string + publicIP string + subnetID string + instanceType string + tags map[string]string +} + +// describeEC2Instances returns a map of EC2 instance ID to instance information. +// Uses concurrent requests limited by RequestConcurrency to respect AWS API throttling. +// This method does not use concurrency as it's a simple paginated call. +func (d *ECSDiscovery) describeEC2Instances(ctx context.Context, instanceIDs []string) (map[string]ec2InstanceInfo, error) { + if len(instanceIDs) == 0 { + return make(map[string]ec2InstanceInfo), nil + } + + instanceInfo := make(map[string]ec2InstanceInfo) + var nextToken *string + + for { + resp, err := d.ec2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{ + InstanceIds: instanceIDs, + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("could not describe EC2 instances: %w", err) + } + + for _, reservation := range resp.Reservations { + for _, instance := range reservation.Instances { + if instance.InstanceId != nil && instance.PrivateIpAddress != nil { + info := ec2InstanceInfo{ + privateIP: *instance.PrivateIpAddress, + tags: make(map[string]string), + } + if instance.PublicIpAddress != nil { + info.publicIP = *instance.PublicIpAddress + } + if instance.SubnetId != nil { + info.subnetID = *instance.SubnetId + } + if instance.InstanceType != "" { + info.instanceType = string(instance.InstanceType) + } + // Collect EC2 instance tags + for _, tag := range instance.Tags { + if tag.Key != nil && tag.Value != nil { + info.tags[*tag.Key] = *tag.Value + } + } + instanceInfo[*instance.InstanceId] = info + } + } + } + + if resp.NextToken == nil { + break + } + nextToken = resp.NextToken + } + + return instanceInfo, nil +} + +// describeNetworkInterfaces returns a map of ENI ID to public IP address. +// This is needed to get the public IP for tasks using awsvpc network mode, as the ENI is what gets the public IP, not the EC2 instance. +// This method does not use concurrency as it's a simple paginated call. +func (d *ECSDiscovery) describeNetworkInterfaces(ctx context.Context, tasks []types.Task) (map[string]string, error) { + eniIDs := make([]string, 0, len(tasks)) + + for _, task := range tasks { + for _, attachment := range task.Attachments { + if attachment.Type != nil && *attachment.Type == "ElasticNetworkInterface" { + for _, detail := range attachment.Details { + if detail.Name != nil && *detail.Name == "networkInterfaceId" && detail.Value != nil { + eniIDs = append(eniIDs, *detail.Value) + break + } + } + break + } + } + } + + if len(eniIDs) == 0 { + return make(map[string]string), nil + } + + eniToPublicIP := make(map[string]string) + var nextToken *string + + for { + resp, err := d.ec2.DescribeNetworkInterfaces(ctx, &ec2.DescribeNetworkInterfacesInput{ + NetworkInterfaceIds: eniIDs, + NextToken: nextToken, + }) + if err != nil { + return nil, fmt.Errorf("could not describe network interfaces: %w", err) + } + + for _, eni := range resp.NetworkInterfaces { + if eni.NetworkInterfaceId != nil && eni.Association != nil && eni.Association.PublicIp != nil { + eniToPublicIP[*eni.NetworkInterfaceId] = *eni.Association.PublicIp + } + } + + if resp.NextToken == nil { + break + } + nextToken = resp.NextToken + } + + return eniToPublicIP, nil +} + +func (d *ECSDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { + err := d.initEcsClient(ctx) + if err != nil { + return nil, err + } + + var clusters []string + if len(d.cfg.Clusters) == 0 { + clusters, err = d.listClusterARNs(ctx) + if err != nil { + return nil, err + } + } else { + clusters = d.cfg.Clusters + } + + if len(clusters) == 0 { + return []*targetgroup.Group{ + { + Source: d.cfg.Region, + }, + }, nil + } + + tg := &targetgroup.Group{ + Source: d.cfg.Region, + } + + // Fetch cluster details, service ARNs, and task ARNs in parallel + var ( + clusterMap map[string]types.Cluster + serviceMap map[string][]string + taskMap map[string][]string + ) + + clusterErrg, clusterCtx := errgroup.WithContext(ctx) + clusterErrg.Go(func() error { + var err error + clusterMap, err = d.describeClusters(clusterCtx, clusters) + return err + }) + clusterErrg.Go(func() error { + var err error + serviceMap, err = d.listServiceARNs(clusterCtx, clusters) + return err + }) + clusterErrg.Go(func() error { + var err error + taskMap, err = d.listTaskARNs(clusterCtx, clusters) + return err + }) + + if err := clusterErrg.Wait(); err != nil { + return nil, err + } + + // Use goroutines to process clusters in parallel + var ( + clusterWg sync.WaitGroup + clusterMu sync.Mutex + clusterTargets []model.LabelSet + ) + + for clusterARN, taskARNs := range taskMap { + if len(taskARNs) == 0 { + continue + } + + clusterWg.Add(1) + + go func(cluster types.Cluster, serviceARNs, taskARNs []string) { + defer clusterWg.Done() + + // Fetch services and tasks in parallel (they're independent) + var ( + services map[string]types.Service + tasks []types.Task + ) + + resourceErrg, resourceCtx := errgroup.WithContext(ctx) + resourceErrg.Go(func() error { + var err error + services, err = d.describeServices(resourceCtx, *cluster.ClusterArn, serviceARNs) + if err != nil { + d.logger.Error("Failed to describe services for cluster", "cluster", *cluster.ClusterArn, "error", err) + } + return err + }) + resourceErrg.Go(func() error { + var err error + tasks, err = d.describeTasks(resourceCtx, *cluster.ClusterArn, taskARNs) + if err != nil { + d.logger.Error("Failed to describe tasks for cluster", "cluster", *cluster.ClusterArn, "error", err) + } + return err + }) + + if err := resourceErrg.Wait(); err != nil { + return + } + + // Fetch container instances and network interfaces in parallel (both depend on tasks) + var ( + containerInstances map[string]string + eniToPublicIP map[string]string + ) + + instanceErrg, instanceCtx := errgroup.WithContext(ctx) + instanceErrg.Go(func() error { + var err error + containerInstances, err = d.describeContainerInstances(instanceCtx, *cluster.ClusterArn, tasks) + if err != nil { + d.logger.Error("Failed to describe container instances for cluster", "cluster", *cluster.ClusterArn, "error", err) + } + return err + }) + instanceErrg.Go(func() error { + var err error + eniToPublicIP, err = d.describeNetworkInterfaces(instanceCtx, tasks) + if err != nil { + d.logger.Error("Failed to describe network interfaces for cluster", "cluster", *cluster.ClusterArn, "error", err) + } + return err + }) + + if err := instanceErrg.Wait(); err != nil { + return + } + + ec2Instances := make(map[string]ec2InstanceInfo) + if len(containerInstances) > 0 { + // Deduplicate EC2 instance IDs (multiple tasks can share the same instance) + ec2InstanceIDSet := make(map[string]struct{}) + for _, ec2ID := range containerInstances { + ec2InstanceIDSet[ec2ID] = struct{}{} + } + ec2InstanceIDs := make([]string, 0, len(ec2InstanceIDSet)) + for ec2ID := range ec2InstanceIDSet { + ec2InstanceIDs = append(ec2InstanceIDs, ec2ID) + } + ec2Instances, err = d.describeEC2Instances(ctx, ec2InstanceIDs) + if err != nil { + d.logger.Error("Failed to describe EC2 instances for cluster", "cluster", *cluster.ClusterArn, "error", err) + return + } + } + + var ( + taskWg sync.WaitGroup + taskMu sync.Mutex + taskTargets []model.LabelSet + ) + + for _, task := range tasks { + taskWg.Add(1) + + go func(cluster types.Cluster, services map[string]types.Service, task types.Task, containerInstances map[string]string, ec2Instances map[string]ec2InstanceInfo, eniToPublicIP map[string]string) { + defer taskWg.Done() + + var ( + ipAddress, subnetID, publicIP string + networkMode string + ec2InstanceID, ec2InstanceType, ec2InstancePrivateIP, ec2InstancePublicIP string + ) + + // Try to get IP from ENI attachment (awsvpc mode) + var eniAttachment *types.Attachment + for _, attachment := range task.Attachments { + if attachment.Type != nil && *attachment.Type == "ElasticNetworkInterface" { + eniAttachment = &attachment + break + } + } + + if eniAttachment != nil { + // awsvpc networking mode - get IP from ENI + networkMode = "awsvpc" + var eniID string + for _, detail := range eniAttachment.Details { + switch *detail.Name { + case "privateIPv4Address": + ipAddress = *detail.Value + case "subnetId": + subnetID = *detail.Value + case "networkInterfaceId": + eniID = *detail.Value + } + } + // Get public IP from ENI if available + if eniID != "" { + if pub, ok := eniToPublicIP[eniID]; ok { + publicIP = pub + } + } + } else if task.ContainerInstanceArn != nil { + // bridge/host networking mode - need to get EC2 instance IP and subnet + networkMode = "bridge" + var ok bool + ec2InstanceID, ok = containerInstances[*task.ContainerInstanceArn] + if ok { + info, ok := ec2Instances[ec2InstanceID] + if ok { + ipAddress = info.privateIP + publicIP = info.publicIP + subnetID = info.subnetID + ec2InstanceType = info.instanceType + ec2InstancePrivateIP = info.privateIP + ec2InstancePublicIP = info.publicIP + } else { + d.logger.Debug("EC2 instance info not found", "instance", ec2InstanceID, "task", *task.TaskArn) + } + } else { + d.logger.Debug("Container instance not found in map", "arn", *task.ContainerInstanceArn, "task", *task.TaskArn) + } + } + + // Get EC2 instance metadata for awsvpc tasks running on EC2 + // We want the instance type and the host IPs for advanced use cases + if networkMode == "awsvpc" && task.ContainerInstanceArn != nil { + var ok bool + ec2InstanceID, ok = containerInstances[*task.ContainerInstanceArn] + if ok { + info, ok := ec2Instances[ec2InstanceID] + if ok { + ec2InstanceType = info.instanceType + ec2InstancePrivateIP = info.privateIP + ec2InstancePublicIP = info.publicIP + } + } + } + + if ipAddress == "" { + return + } + + labels := model.LabelSet{ + ecsLabelClusterARN: model.LabelValue(*cluster.ClusterArn), + ecsLabelCluster: model.LabelValue(*cluster.ClusterName), + ecsLabelTaskGroup: model.LabelValue(*task.Group), + ecsLabelTaskARN: model.LabelValue(*task.TaskArn), + ecsLabelTaskDefinition: model.LabelValue(*task.TaskDefinitionArn), + ecsLabelIPAddress: model.LabelValue(ipAddress), + ecsLabelRegion: model.LabelValue(d.cfg.Region), + ecsLabelLaunchType: model.LabelValue(task.LaunchType), + ecsLabelAvailabilityZone: model.LabelValue(*task.AvailabilityZone), + ecsLabelDesiredStatus: model.LabelValue(*task.DesiredStatus), + ecsLabelLastStatus: model.LabelValue(*task.LastStatus), + ecsLabelHealthStatus: model.LabelValue(task.HealthStatus), + ecsLabelNetworkMode: model.LabelValue(networkMode), + } + + // Add subnet ID when available (awsvpc mode from ENI, bridge/host from EC2 instance) + if subnetID != "" { + labels[ecsLabelSubnetID] = model.LabelValue(subnetID) + } + + // Add container instance and EC2 instance info for EC2 launch type + if task.ContainerInstanceArn != nil { + labels[ecsLabelContainerInstanceARN] = model.LabelValue(*task.ContainerInstanceArn) + } + if ec2InstanceID != "" { + labels[ecsLabelEC2InstanceID] = model.LabelValue(ec2InstanceID) + } + if ec2InstanceType != "" { + labels[ecsLabelEC2InstanceType] = model.LabelValue(ec2InstanceType) + } + if ec2InstancePrivateIP != "" { + labels[ecsLabelEC2InstancePrivateIP] = model.LabelValue(ec2InstancePrivateIP) + } + if ec2InstancePublicIP != "" { + labels[ecsLabelEC2InstancePublicIP] = model.LabelValue(ec2InstancePublicIP) + } + if publicIP != "" { + labels[ecsLabelPublicIP] = model.LabelValue(publicIP) + } + + if task.PlatformFamily != nil { + labels[ecsLabelPlatformFamily] = model.LabelValue(*task.PlatformFamily) + } + if task.PlatformVersion != nil { + labels[ecsLabelPlatformVersion] = model.LabelValue(*task.PlatformVersion) + } + + labels[model.AddressLabel] = model.LabelValue(net.JoinHostPort(ipAddress, strconv.Itoa(d.cfg.Port))) + + // Add cluster tags + for _, clusterTag := range cluster.Tags { + if clusterTag.Key != nil && clusterTag.Value != nil { + labels[model.LabelName(ecsLabelTagCluster+strutil.SanitizeLabelName(*clusterTag.Key))] = model.LabelValue(*clusterTag.Value) + } + } + + // If this is not a standalone task, add service information and tags + if !isStandaloneTask(task) { + service, ok := services[getServiceNameFromTaskGroup(task)] + if !ok { + d.logger.Debug("Service not found for task", "task", *task.TaskArn, "service", getServiceNameFromTaskGroup(task)) + } + if service.ServiceName != nil { + labels[ecsLabelService] = model.LabelValue(*service.ServiceName) + } + if service.ServiceArn != nil { + labels[ecsLabelServiceARN] = model.LabelValue(*service.ServiceArn) + } + if service.Status != nil { + labels[ecsLabelServiceStatus] = model.LabelValue(*service.Status) + } + + // Add service tags + for _, serviceTag := range service.Tags { + if serviceTag.Key != nil && serviceTag.Value != nil { + labels[model.LabelName(ecsLabelTagService+strutil.SanitizeLabelName(*serviceTag.Key))] = model.LabelValue(*serviceTag.Value) + } + } + } + + // Add task tags + for _, taskTag := range task.Tags { + if taskTag.Key != nil && taskTag.Value != nil { + labels[model.LabelName(ecsLabelTagTask+strutil.SanitizeLabelName(*taskTag.Key))] = model.LabelValue(*taskTag.Value) + } + } + + // Add EC2 instance tags (if running on EC2) + if ec2InstanceID != "" { + if info, ok := ec2Instances[ec2InstanceID]; ok { + for tagKey, tagValue := range info.tags { + labels[model.LabelName(ecsLabelTagEC2+strutil.SanitizeLabelName(tagKey))] = model.LabelValue(tagValue) + } + } + } + + taskMu.Lock() + taskTargets = append(taskTargets, labels) + taskMu.Unlock() + }(cluster, services, task, containerInstances, ec2Instances, eniToPublicIP) + } + + taskWg.Wait() + + // Add this cluster's task targets to the overall collection + clusterMu.Lock() + clusterTargets = append(clusterTargets, taskTargets...) + clusterMu.Unlock() + }(clusterMap[clusterARN], serviceMap[clusterARN], taskARNs) + } + + clusterWg.Wait() + + // Set all targets to the target group + tg.Targets = clusterTargets + + return []*targetgroup.Group{tg}, nil +} + +func isStandaloneTask(task types.Task) bool { + // A standalone task will have a group of "family:task-def-name" + return task.Group != nil && strings.HasPrefix(*task.Group, "family:") +} + +func getServiceNameFromTaskGroup(task types.Task) string { + return strings.Split(*task.Group, ":")[1] +} diff --git a/discovery/aws/ecs_test.go b/discovery/aws/ecs_test.go new file mode 100644 index 0000000000..bb1f96a28e --- /dev/null +++ b/discovery/aws/ecs_test.go @@ -0,0 +1,1807 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/ecs" + ecsTypes "github.com/aws/aws-sdk-go-v2/service/ecs/types" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/prometheus/prometheus/discovery/targetgroup" +) + +// Struct for test data. +type ecsDataStore struct { + region string + + clusters []ecsTypes.Cluster + services []ecsTypes.Service + tasks []ecsTypes.Task + containerInstances []ecsTypes.ContainerInstance + ec2Instances map[string]ec2InstanceInfo // EC2 instance ID to instance info + eniPublicIPs map[string]string // ENI ID to public IP +} + +func TestECSDiscoveryListClusterARNs(t *testing.T) { + ctx := context.Background() + + // iterate through the test cases + for _, tt := range []struct { + name string + ecsData *ecsDataStore + expected []string + }{ + { + name: "MultipleClusters", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + }, + { + ClusterName: strptr("prod-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/prod-cluster"), + Status: strptr("ACTIVE"), + }, + }, + }, + expected: []string{ + "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + "arn:aws:ecs:us-west-2:123456789012:cluster/prod-cluster", + }, + }, + { + name: "SingleCluster", + ecsData: &ecsDataStore{ + region: "us-east-1", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("single-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-east-1:123456789012:cluster/single-cluster"), + Status: strptr("ACTIVE"), + }, + }, + }, + expected: []string{ + "arn:aws:ecs:us-east-1:123456789012:cluster/single-cluster", + }, + }, + { + name: "NoClusters", + ecsData: &ecsDataStore{ + region: "us-east-1", + clusters: []ecsTypes.Cluster{}, + }, + expected: nil, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockECSClient(tt.ecsData) + + d := &ECSDiscovery{ + ecs: client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 10, + }, + } + + clusters, err := d.listClusterARNs(ctx) + require.NoError(t, err) + require.Equal(t, tt.expected, clusters) + }) + } +} + +func TestECSDiscoveryDescribeClusters(t *testing.T) { + ctx := context.Background() + + // iterate through the test cases + for _, tt := range []struct { + name string + ecsData *ecsDataStore + clusterARNs []string + expected map[string]ecsTypes.Cluster + }{ + { + name: "SingleClusterWithTags", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("test")}, + {Key: strptr("Team"), Value: strptr("backend")}, + }, + }, + }, + }, + clusterARNs: []string{"arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"}, + expected: map[string]ecsTypes.Cluster{ + "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster": { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("test")}, + {Key: strptr("Team"), Value: strptr("backend")}, + }, + }, + }, + }, + { + name: "MultipleClusters", + ecsData: &ecsDataStore{ + region: "us-east-1", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("cluster-1"), + ClusterArn: strptr("arn:aws:ecs:us-east-1:123456789012:cluster/cluster-1"), + Status: strptr("ACTIVE"), + }, + { + ClusterName: strptr("cluster-2"), + ClusterArn: strptr("arn:aws:ecs:us-east-1:123456789012:cluster/cluster-2"), + Status: strptr("DRAINING"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Stage"), Value: strptr("prod")}, + }, + }, + }, + }, + clusterARNs: []string{ + "arn:aws:ecs:us-east-1:123456789012:cluster/cluster-1", + "arn:aws:ecs:us-east-1:123456789012:cluster/cluster-2", + }, + expected: map[string]ecsTypes.Cluster{ + "arn:aws:ecs:us-east-1:123456789012:cluster/cluster-1": { + ClusterName: strptr("cluster-1"), + ClusterArn: strptr("arn:aws:ecs:us-east-1:123456789012:cluster/cluster-1"), + Status: strptr("ACTIVE"), + }, + "arn:aws:ecs:us-east-1:123456789012:cluster/cluster-2": { + ClusterName: strptr("cluster-2"), + ClusterArn: strptr("arn:aws:ecs:us-east-1:123456789012:cluster/cluster-2"), + Status: strptr("DRAINING"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Stage"), Value: strptr("prod")}, + }, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockECSClient(tt.ecsData) + + d := &ECSDiscovery{ + ecs: client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 10, + }, + } + + clusterMap, err := d.describeClusters(ctx, tt.clusterARNs) + require.NoError(t, err) + require.Equal(t, tt.expected, clusterMap) + }) + } +} + +func TestECSDiscoveryListServiceARNs(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + ecsData *ecsDataStore + clusterARNs []string + expected map[string][]string + }{ + { + name: "SingleClusterWithServices", + ecsData: &ecsDataStore{ + region: "us-west-2", + services: []ecsTypes.Service{ + { + ServiceName: strptr("web-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/web-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + }, + { + ServiceName: strptr("api-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/api-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + }, + }, + }, + clusterARNs: []string{"arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"}, + expected: map[string][]string{ + "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster": { + "arn:aws:ecs:us-west-2:123456789012:service/test-cluster/web-service", + "arn:aws:ecs:us-west-2:123456789012:service/test-cluster/api-service", + }, + }, + }, + { + name: "MultipleClusters", + ecsData: &ecsDataStore{ + region: "us-west-2", + services: []ecsTypes.Service{ + { + ServiceName: strptr("web-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/cluster-1/web-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/cluster-1"), + Status: strptr("ACTIVE"), + }, + { + ServiceName: strptr("api-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/cluster-2/api-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/cluster-2"), + Status: strptr("ACTIVE"), + }, + }, + }, + clusterARNs: []string{ + "arn:aws:ecs:us-west-2:123456789012:cluster/cluster-1", + "arn:aws:ecs:us-west-2:123456789012:cluster/cluster-2", + }, + expected: map[string][]string{ + "arn:aws:ecs:us-west-2:123456789012:cluster/cluster-1": { + "arn:aws:ecs:us-west-2:123456789012:service/cluster-1/web-service", + }, + "arn:aws:ecs:us-west-2:123456789012:cluster/cluster-2": { + "arn:aws:ecs:us-west-2:123456789012:service/cluster-2/api-service", + }, + }, + }, + { + name: "EmptyCluster", + ecsData: &ecsDataStore{ + region: "us-west-2", + services: []ecsTypes.Service{}, + }, + clusterARNs: []string{"arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"}, + expected: map[string][]string{ + "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster": nil, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockECSClient(tt.ecsData) + + d := &ECSDiscovery{ + ecs: client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 2, + }, + } + + serviceMap, err := d.listServiceARNs(ctx, tt.clusterARNs) + require.NoError(t, err) + require.Equal(t, tt.expected, serviceMap) + }) + } +} + +func TestECSDiscoveryDescribeServices(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + ecsData *ecsDataStore + clusterARN string + serviceARNs []string + expected map[string]ecsTypes.Service + }{ + { + name: "ServicesWithTags", + ecsData: &ecsDataStore{ + region: "us-west-2", + services: []ecsTypes.Service{ + { + ServiceName: strptr("web-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/web-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("production")}, + {Key: strptr("Team"), Value: strptr("platform")}, + }, + }, + { + ServiceName: strptr("api-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/api-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("staging")}, + }, + }, + }, + }, + clusterARN: "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + serviceARNs: []string{ + "arn:aws:ecs:us-west-2:123456789012:service/test-cluster/web-service", + "arn:aws:ecs:us-west-2:123456789012:service/test-cluster/api-service", + }, + expected: map[string]ecsTypes.Service{ + "web-service": { + ServiceName: strptr("web-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/web-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("production")}, + {Key: strptr("Team"), Value: strptr("platform")}, + }, + }, + "api-service": { + ServiceName: strptr("api-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/api-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("staging")}, + }, + }, + }, + }, + { + name: "EmptyServiceList", + ecsData: &ecsDataStore{ + region: "us-west-2", + services: []ecsTypes.Service{}, + }, + clusterARN: "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + serviceARNs: []string{}, + expected: map[string]ecsTypes.Service{}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockECSClient(tt.ecsData) + + d := &ECSDiscovery{ + ecs: client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 2, + }, + } + + services, err := d.describeServices(ctx, tt.clusterARN, tt.serviceARNs) + require.NoError(t, err) + require.Equal(t, tt.expected, services) + }) + } +} + +func TestECSDiscoveryDescribeContainerInstances(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + ecsData *ecsDataStore + clusterARN string + tasks []ecsTypes.Task + expected map[string]string + }{ + { + name: "EC2Tasks", + ecsData: &ecsDataStore{ + region: "us-west-2", + containerInstances: []ecsTypes.ContainerInstance{ + { + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123"), + Ec2InstanceId: strptr("i-1234567890abcdef0"), + }, + { + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/xyz789"), + Ec2InstanceId: strptr("i-0987654321fedcba0"), + }, + }, + }, + clusterARN: "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123"), + LaunchType: ecsTypes.LaunchTypeEc2, + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-2"), + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/xyz789"), + LaunchType: ecsTypes.LaunchTypeEc2, + }, + }, + expected: map[string]string{ + "arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123": "i-1234567890abcdef0", + "arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/xyz789": "i-0987654321fedcba0", + }, + }, + { + name: "FargateTasks", + ecsData: &ecsDataStore{ + region: "us-west-2", + containerInstances: []ecsTypes.ContainerInstance{}, + }, + clusterARN: "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + LaunchType: ecsTypes.LaunchTypeFargate, + }, + }, + expected: map[string]string{}, + }, + { + name: "MixedTasks", + ecsData: &ecsDataStore{ + region: "us-west-2", + containerInstances: []ecsTypes.ContainerInstance{ + { + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123"), + Ec2InstanceId: strptr("i-1234567890abcdef0"), + }, + }, + }, + clusterARN: "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-ec2"), + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123"), + LaunchType: ecsTypes.LaunchTypeEc2, + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-fargate"), + LaunchType: ecsTypes.LaunchTypeFargate, + }, + }, + expected: map[string]string{ + "arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123": "i-1234567890abcdef0", + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockECSClient(tt.ecsData) + + d := &ECSDiscovery{ + ecs: client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 2, + }, + } + + containerInstances, err := d.describeContainerInstances(ctx, tt.clusterARN, tt.tasks) + require.NoError(t, err) + require.Equal(t, tt.expected, containerInstances) + }) + } +} + +func TestECSDiscoveryDescribeEC2Instances(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + ecsData *ecsDataStore + instanceIDs []string + expected map[string]ec2InstanceInfo + }{ + { + name: "InstancesWithTags", + ecsData: &ecsDataStore{ + region: "us-west-2", + ec2Instances: map[string]ec2InstanceInfo{ + "i-1234567890abcdef0": { + privateIP: "10.0.1.50", + publicIP: "54.1.2.3", + subnetID: "subnet-12345", + instanceType: "t3.medium", + tags: map[string]string{ + "Name": "ecs-host-1", + "Environment": "production", + }, + }, + "i-0987654321fedcba0": { + privateIP: "10.0.1.75", + publicIP: "54.2.3.4", + subnetID: "subnet-67890", + instanceType: "t3.large", + tags: map[string]string{ + "Name": "ecs-host-2", + "Team": "platform", + }, + }, + }, + }, + instanceIDs: []string{"i-1234567890abcdef0", "i-0987654321fedcba0"}, + expected: map[string]ec2InstanceInfo{ + "i-1234567890abcdef0": { + privateIP: "10.0.1.50", + publicIP: "54.1.2.3", + subnetID: "subnet-12345", + instanceType: "t3.medium", + tags: map[string]string{ + "Name": "ecs-host-1", + "Environment": "production", + }, + }, + "i-0987654321fedcba0": { + privateIP: "10.0.1.75", + publicIP: "54.2.3.4", + subnetID: "subnet-67890", + instanceType: "t3.large", + tags: map[string]string{ + "Name": "ecs-host-2", + "Team": "platform", + }, + }, + }, + }, + { + name: "EmptyList", + ecsData: &ecsDataStore{ + region: "us-west-2", + ec2Instances: map[string]ec2InstanceInfo{}, + }, + instanceIDs: []string{}, + expected: map[string]ec2InstanceInfo{}, + }, + { + name: "InstanceWithoutPublicIP", + ecsData: &ecsDataStore{ + region: "us-west-2", + ec2Instances: map[string]ec2InstanceInfo{ + "i-privateonly": { + privateIP: "10.0.1.100", + publicIP: "", + subnetID: "subnet-private", + instanceType: "t3.micro", + tags: map[string]string{}, + }, + }, + }, + instanceIDs: []string{"i-privateonly"}, + expected: map[string]ec2InstanceInfo{ + "i-privateonly": { + privateIP: "10.0.1.100", + publicIP: "", + subnetID: "subnet-private", + instanceType: "t3.micro", + tags: map[string]string{}, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ec2Client := newMockECSEC2Client(tt.ecsData.ec2Instances, nil) + + d := &ECSDiscovery{ + ec2: ec2Client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 2, + }, + } + + instances, err := d.describeEC2Instances(ctx, tt.instanceIDs) + require.NoError(t, err) + require.Equal(t, tt.expected, instances) + }) + } +} + +func TestECSDiscoveryDescribeNetworkInterfaces(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + ecsData *ecsDataStore + tasks []ecsTypes.Task + expected map[string]string + }{ + { + name: "AwsvpcTasksWithPublicIPs", + ecsData: &ecsDataStore{ + region: "us-west-2", + eniPublicIPs: map[string]string{ + "eni-12345": "52.1.2.3", + "eni-67890": "52.2.3.4", + }, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + LaunchType: ecsTypes.LaunchTypeFargate, + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("networkInterfaceId"), Value: strptr("eni-12345")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.1.100")}, + }, + }, + }, + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-2"), + LaunchType: ecsTypes.LaunchTypeFargate, + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("networkInterfaceId"), Value: strptr("eni-67890")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.1.200")}, + }, + }, + }, + }, + }, + expected: map[string]string{ + "eni-12345": "52.1.2.3", + "eni-67890": "52.2.3.4", + }, + }, + { + name: "AwsvpcTasksWithoutPublicIPs", + ecsData: &ecsDataStore{ + region: "us-west-2", + eniPublicIPs: map[string]string{}, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + LaunchType: ecsTypes.LaunchTypeFargate, + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("networkInterfaceId"), Value: strptr("eni-private")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.1.100")}, + }, + }, + }, + }, + }, + expected: map[string]string{}, + }, + { + name: "BridgeTasksNoENI", + ecsData: &ecsDataStore{ + region: "us-west-2", + eniPublicIPs: map[string]string{}, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + LaunchType: ecsTypes.LaunchTypeEc2, + // No ENI attachment for bridge networking + Attachments: []ecsTypes.Attachment{}, + }, + }, + expected: map[string]string{}, + }, + { + name: "MixedTasks", + ecsData: &ecsDataStore{ + region: "us-west-2", + eniPublicIPs: map[string]string{ + "eni-fargate": "52.1.2.3", + }, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-fargate"), + LaunchType: ecsTypes.LaunchTypeFargate, + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("networkInterfaceId"), Value: strptr("eni-fargate")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.1.100")}, + }, + }, + }, + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-bridge"), + LaunchType: ecsTypes.LaunchTypeEc2, + Attachments: []ecsTypes.Attachment{}, + }, + }, + expected: map[string]string{ + "eni-fargate": "52.1.2.3", + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + ec2Client := newMockECSEC2Client(nil, tt.ecsData.eniPublicIPs) + + d := &ECSDiscovery{ + ec2: ec2Client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 2, + }, + } + + eniMap, err := d.describeNetworkInterfaces(ctx, tt.tasks) + require.NoError(t, err) + require.Equal(t, tt.expected, eniMap) + }) + } +} + +func TestECSDiscoveryListTaskARNs(t *testing.T) { + ctx := context.Background() + + // iterate through the test cases + for _, tt := range []struct { + name string + ecsData *ecsDataStore + clusterARNs []string + expected map[string][]string + }{ + { + name: "TasksInCluster", + ecsData: &ecsDataStore{ + region: "us-west-2", + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Group: strptr("service:web-service"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/web-task:1"), + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-2"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Group: strptr("service:web-service"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/web-task:1"), + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-3"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Group: strptr("service:api-service"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/api-task:2"), + }, + }, + }, + clusterARNs: []string{"arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"}, + expected: map[string][]string{ + "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster": { + "arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1", + "arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-2", + "arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-3", + }, + }, + }, + { + name: "EmptyCluster", + ecsData: &ecsDataStore{ + region: "us-west-2", + tasks: []ecsTypes.Task{}, + }, + clusterARNs: []string{"arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"}, + expected: map[string][]string{ + "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster": nil, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockECSClient(tt.ecsData) + + d := &ECSDiscovery{ + ecs: client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 1, + }, + } + + taskMap, err := d.listTaskARNs(ctx, tt.clusterARNs) + require.NoError(t, err) + require.Equal(t, tt.expected, taskMap) + }) + } +} + +func TestECSDiscoveryDescribeTasks(t *testing.T) { + ctx := context.Background() + + // iterate through the test cases + for _, tt := range []struct { + name string + ecsData *ecsDataStore + clusterARN string + taskARNs []string + expected []ecsTypes.Task + }{ + { + name: "TasksInCluster", + ecsData: &ecsDataStore{ + region: "us-west-2", + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Group: strptr("service:web-service"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/web-task:1"), + LastStatus: strptr("RUNNING"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("production")}, + }, + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-2"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Group: strptr("service:api-service"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/api-task:2"), + LastStatus: strptr("RUNNING"), + }, + }, + }, + clusterARN: "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + taskARNs: []string{ + "arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1", + "arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-2", + }, + expected: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Group: strptr("service:web-service"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/web-task:1"), + LastStatus: strptr("RUNNING"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("production")}, + }, + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-2"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Group: strptr("service:api-service"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/api-task:2"), + LastStatus: strptr("RUNNING"), + }, + }, + }, + { + name: "EmptyTaskList", + ecsData: &ecsDataStore{ + region: "us-west-2", + tasks: []ecsTypes.Task{}, + }, + clusterARN: "arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster", + taskARNs: []string{}, + expected: nil, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockECSClient(tt.ecsData) + + d := &ECSDiscovery{ + ecs: client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + RequestConcurrency: 1, + }, + } + + tasks, err := d.describeTasks(ctx, tt.clusterARN, tt.taskARNs) + require.NoError(t, err) + require.Equal(t, tt.expected, tasks) + }) + } +} + +func TestECSDiscoveryRefresh(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + ecsData *ecsDataStore + expected []*targetgroup.Group + }{ + { + name: "SingleClusterWithTasks", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("Environment"), Value: strptr("test")}, + }, + }, + }, + services: []ecsTypes.Service{ + { + ServiceName: strptr("web-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/web-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + Tags: []ecsTypes.Tag{ + {Key: strptr("App"), Value: strptr("web")}, + }, + }, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/web-task:1"), + Group: strptr("service:web-service"), + LaunchType: ecsTypes.LaunchTypeFargate, + LastStatus: strptr("RUNNING"), + DesiredStatus: strptr("RUNNING"), + HealthStatus: ecsTypes.HealthStatusHealthy, + AvailabilityZone: strptr("us-west-2a"), + PlatformFamily: strptr("Linux"), + PlatformVersion: strptr("1.4.0"), + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("subnetId"), Value: strptr("subnet-12345")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.1.100")}, + {Name: strptr("networkInterfaceId"), Value: strptr("eni-fargate-123")}, + }, + }, + }, + Tags: []ecsTypes.Tag{ + {Key: strptr("Version"), Value: strptr("v1.0")}, + }, + }, + }, + eniPublicIPs: map[string]string{ + "eni-fargate-123": "52.1.2.3", + }, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("10.0.1.100:80"), + "__meta_ecs_cluster": model.LabelValue("test-cluster"), + "__meta_ecs_cluster_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + "__meta_ecs_service": model.LabelValue("web-service"), + "__meta_ecs_service_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/web-service"), + "__meta_ecs_service_status": model.LabelValue("ACTIVE"), + "__meta_ecs_task_group": model.LabelValue("service:web-service"), + "__meta_ecs_task_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + "__meta_ecs_task_definition": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task-definition/web-task:1"), + "__meta_ecs_region": model.LabelValue("us-west-2"), + "__meta_ecs_availability_zone": model.LabelValue("us-west-2a"), + "__meta_ecs_subnet_id": model.LabelValue("subnet-12345"), + "__meta_ecs_ip_address": model.LabelValue("10.0.1.100"), + "__meta_ecs_launch_type": model.LabelValue("FARGATE"), + "__meta_ecs_desired_status": model.LabelValue("RUNNING"), + "__meta_ecs_last_status": model.LabelValue("RUNNING"), + "__meta_ecs_health_status": model.LabelValue("HEALTHY"), + "__meta_ecs_platform_family": model.LabelValue("Linux"), + "__meta_ecs_platform_version": model.LabelValue("1.4.0"), + "__meta_ecs_network_mode": model.LabelValue("awsvpc"), + "__meta_ecs_public_ip": model.LabelValue("52.1.2.3"), + "__meta_ecs_tag_cluster_Environment": model.LabelValue("test"), + "__meta_ecs_tag_service_App": model.LabelValue("web"), + "__meta_ecs_tag_task_Version": model.LabelValue("v1.0"), + }, + }, + }, + }, + }, + { + name: "NoTasks", + ecsData: &ecsDataStore{ + region: "us-east-1", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("empty-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-east-1:123456789012:cluster/empty-cluster"), + Status: strptr("ACTIVE"), + }, + }, + services: []ecsTypes.Service{ + { + ServiceName: strptr("empty-service"), + ServiceArn: strptr("arn:aws:ecs:us-east-1:123456789012:service/empty-cluster/empty-service"), + ClusterArn: strptr("arn:aws:ecs:us-east-1:123456789012:cluster/empty-cluster"), + Status: strptr("ACTIVE"), + }, + }, + tasks: []ecsTypes.Task{}, + }, + expected: []*targetgroup.Group{ + { + Source: "us-east-1", + }, + }, + }, + { + name: "TaskWithoutENI", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + }, + }, + services: []ecsTypes.Service{ + { + ServiceName: strptr("service-1"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/service-1"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + }, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-1"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/task-def:1"), + Group: strptr("service:service-1"), + LaunchType: ecsTypes.LaunchTypeEc2, + LastStatus: strptr("RUNNING"), + DesiredStatus: strptr("RUNNING"), + HealthStatus: ecsTypes.HealthStatusHealthy, + AvailabilityZone: strptr("us-west-2a"), + // No attachments - should be skipped + Attachments: []ecsTypes.Attachment{}, + }, + }, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + }, + }, + }, + { + name: "StandaloneTaskNoService", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("standalone-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/standalone-cluster"), + Status: strptr("ACTIVE"), + }, + }, + services: []ecsTypes.Service{}, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/standalone-cluster/task-standalone"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/standalone-cluster"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/standalone-task:1"), + Group: strptr("family:standalone-task"), + LaunchType: ecsTypes.LaunchTypeFargate, + LastStatus: strptr("RUNNING"), + DesiredStatus: strptr("RUNNING"), + HealthStatus: ecsTypes.HealthStatusHealthy, + AvailabilityZone: strptr("us-west-2a"), + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("subnetId"), Value: strptr("subnet-standalone-1")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.4.10")}, + {Name: strptr("networkInterfaceId"), Value: strptr("eni-standalone-123")}, + }, + }, + }, + Tags: []ecsTypes.Tag{ + {Key: strptr("Role"), Value: strptr("batch")}, + }, + }, + }, + eniPublicIPs: map[string]string{ + "eni-standalone-123": "52.4.5.6", + }, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("10.0.4.10:80"), + "__meta_ecs_cluster": model.LabelValue("standalone-cluster"), + "__meta_ecs_cluster_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:cluster/standalone-cluster"), + "__meta_ecs_task_group": model.LabelValue("family:standalone-task"), + "__meta_ecs_task_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task/standalone-cluster/task-standalone"), + "__meta_ecs_task_definition": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task-definition/standalone-task:1"), + "__meta_ecs_region": model.LabelValue("us-west-2"), + "__meta_ecs_availability_zone": model.LabelValue("us-west-2a"), + "__meta_ecs_subnet_id": model.LabelValue("subnet-standalone-1"), + "__meta_ecs_ip_address": model.LabelValue("10.0.4.10"), + "__meta_ecs_launch_type": model.LabelValue("FARGATE"), + "__meta_ecs_desired_status": model.LabelValue("RUNNING"), + "__meta_ecs_last_status": model.LabelValue("RUNNING"), + "__meta_ecs_health_status": model.LabelValue("HEALTHY"), + "__meta_ecs_network_mode": model.LabelValue("awsvpc"), + "__meta_ecs_public_ip": model.LabelValue("52.4.5.6"), + "__meta_ecs_tag_task_Role": model.LabelValue("batch"), + }, + }, + }, + }, + }, + { + name: "TaskWithBridgeNetworking", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + }, + }, + services: []ecsTypes.Service{ + { + ServiceName: strptr("bridge-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/bridge-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + Status: strptr("ACTIVE"), + }, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-bridge"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/bridge-task:1"), + Group: strptr("service:bridge-service"), + LaunchType: ecsTypes.LaunchTypeEc2, + LastStatus: strptr("RUNNING"), + DesiredStatus: strptr("RUNNING"), + HealthStatus: ecsTypes.HealthStatusHealthy, + AvailabilityZone: strptr("us-west-2a"), + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123"), + Attachments: []ecsTypes.Attachment{}, + }, + }, + containerInstances: []ecsTypes.ContainerInstance{ + { + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123"), + Ec2InstanceId: strptr("i-1234567890abcdef0"), + Status: strptr("ACTIVE"), + }, + }, + ec2Instances: map[string]ec2InstanceInfo{ + "i-1234567890abcdef0": { + privateIP: "10.0.1.50", + publicIP: "54.1.2.3", + subnetID: "subnet-bridge-1", + instanceType: "t3.medium", + tags: map[string]string{ + "Name": "ecs-host-1", + "Environment": "production", + }, + }, + }, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("10.0.1.50:80"), + "__meta_ecs_cluster": model.LabelValue("test-cluster"), + "__meta_ecs_cluster_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:cluster/test-cluster"), + "__meta_ecs_service": model.LabelValue("bridge-service"), + "__meta_ecs_service_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:service/test-cluster/bridge-service"), + "__meta_ecs_service_status": model.LabelValue("ACTIVE"), + "__meta_ecs_task_group": model.LabelValue("service:bridge-service"), + "__meta_ecs_task_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task/test-cluster/task-bridge"), + "__meta_ecs_task_definition": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task-definition/bridge-task:1"), + "__meta_ecs_region": model.LabelValue("us-west-2"), + "__meta_ecs_availability_zone": model.LabelValue("us-west-2a"), + "__meta_ecs_ip_address": model.LabelValue("10.0.1.50"), + "__meta_ecs_subnet_id": model.LabelValue("subnet-bridge-1"), + "__meta_ecs_launch_type": model.LabelValue("EC2"), + "__meta_ecs_desired_status": model.LabelValue("RUNNING"), + "__meta_ecs_last_status": model.LabelValue("RUNNING"), + "__meta_ecs_health_status": model.LabelValue("HEALTHY"), + "__meta_ecs_network_mode": model.LabelValue("bridge"), + "__meta_ecs_container_instance_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:container-instance/test-cluster/abc123"), + "__meta_ecs_ec2_instance_id": model.LabelValue("i-1234567890abcdef0"), + "__meta_ecs_ec2_instance_type": model.LabelValue("t3.medium"), + "__meta_ecs_ec2_instance_private_ip": model.LabelValue("10.0.1.50"), + "__meta_ecs_ec2_instance_public_ip": model.LabelValue("54.1.2.3"), + "__meta_ecs_public_ip": model.LabelValue("54.1.2.3"), + "__meta_ecs_tag_ec2_Name": model.LabelValue("ecs-host-1"), + "__meta_ecs_tag_ec2_Environment": model.LabelValue("production"), + }, + }, + }, + }, + }, + { + name: "MixedNetworkingModes", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("mixed-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/mixed-cluster"), + Status: strptr("ACTIVE"), + }, + }, + services: []ecsTypes.Service{ + { + ServiceName: strptr("mixed-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/mixed-cluster/mixed-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/mixed-cluster"), + Status: strptr("ACTIVE"), + }, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/mixed-cluster/task-awsvpc"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/mixed-cluster"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/awsvpc-task:1"), + Group: strptr("service:mixed-service"), + LaunchType: ecsTypes.LaunchTypeFargate, + LastStatus: strptr("RUNNING"), + DesiredStatus: strptr("RUNNING"), + HealthStatus: ecsTypes.HealthStatusHealthy, + AvailabilityZone: strptr("us-west-2a"), + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("subnetId"), Value: strptr("subnet-12345")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.2.100")}, + {Name: strptr("networkInterfaceId"), Value: strptr("eni-mixed-awsvpc")}, + }, + }, + }, + }, + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/mixed-cluster/task-bridge"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/mixed-cluster"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/bridge-task:1"), + Group: strptr("service:mixed-service"), + LaunchType: ecsTypes.LaunchTypeEc2, + LastStatus: strptr("RUNNING"), + DesiredStatus: strptr("RUNNING"), + HealthStatus: ecsTypes.HealthStatusHealthy, + AvailabilityZone: strptr("us-west-2b"), + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/mixed-cluster/xyz789"), + Attachments: []ecsTypes.Attachment{}, + }, + }, + containerInstances: []ecsTypes.ContainerInstance{ + { + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/mixed-cluster/xyz789"), + Ec2InstanceId: strptr("i-0987654321fedcba0"), + Status: strptr("ACTIVE"), + }, + }, + ec2Instances: map[string]ec2InstanceInfo{ + "i-0987654321fedcba0": { + privateIP: "10.0.1.75", + publicIP: "54.2.3.4", + subnetID: "subnet-bridge-2", + instanceType: "t3.large", + tags: map[string]string{ + "Name": "mixed-host", + "Team": "platform", + }, + }, + }, + eniPublicIPs: map[string]string{ + "eni-mixed-awsvpc": "52.2.3.4", + }, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("10.0.2.100:80"), + "__meta_ecs_cluster": model.LabelValue("mixed-cluster"), + "__meta_ecs_cluster_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:cluster/mixed-cluster"), + "__meta_ecs_service": model.LabelValue("mixed-service"), + "__meta_ecs_service_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:service/mixed-cluster/mixed-service"), + "__meta_ecs_service_status": model.LabelValue("ACTIVE"), + "__meta_ecs_task_group": model.LabelValue("service:mixed-service"), + "__meta_ecs_task_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task/mixed-cluster/task-awsvpc"), + "__meta_ecs_task_definition": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task-definition/awsvpc-task:1"), + "__meta_ecs_region": model.LabelValue("us-west-2"), + "__meta_ecs_availability_zone": model.LabelValue("us-west-2a"), + "__meta_ecs_ip_address": model.LabelValue("10.0.2.100"), + "__meta_ecs_subnet_id": model.LabelValue("subnet-12345"), + "__meta_ecs_launch_type": model.LabelValue("FARGATE"), + "__meta_ecs_desired_status": model.LabelValue("RUNNING"), + "__meta_ecs_last_status": model.LabelValue("RUNNING"), + "__meta_ecs_health_status": model.LabelValue("HEALTHY"), + "__meta_ecs_network_mode": model.LabelValue("awsvpc"), + "__meta_ecs_public_ip": model.LabelValue("52.2.3.4"), + }, + { + model.AddressLabel: model.LabelValue("10.0.1.75:80"), + "__meta_ecs_cluster": model.LabelValue("mixed-cluster"), + "__meta_ecs_cluster_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:cluster/mixed-cluster"), + "__meta_ecs_service": model.LabelValue("mixed-service"), + "__meta_ecs_service_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:service/mixed-cluster/mixed-service"), + "__meta_ecs_service_status": model.LabelValue("ACTIVE"), + "__meta_ecs_task_group": model.LabelValue("service:mixed-service"), + "__meta_ecs_task_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task/mixed-cluster/task-bridge"), + "__meta_ecs_task_definition": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task-definition/bridge-task:1"), + "__meta_ecs_region": model.LabelValue("us-west-2"), + "__meta_ecs_availability_zone": model.LabelValue("us-west-2b"), + "__meta_ecs_ip_address": model.LabelValue("10.0.1.75"), + "__meta_ecs_subnet_id": model.LabelValue("subnet-bridge-2"), + "__meta_ecs_launch_type": model.LabelValue("EC2"), + "__meta_ecs_desired_status": model.LabelValue("RUNNING"), + "__meta_ecs_last_status": model.LabelValue("RUNNING"), + "__meta_ecs_health_status": model.LabelValue("HEALTHY"), + "__meta_ecs_network_mode": model.LabelValue("bridge"), + "__meta_ecs_container_instance_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:container-instance/mixed-cluster/xyz789"), + "__meta_ecs_ec2_instance_id": model.LabelValue("i-0987654321fedcba0"), + "__meta_ecs_ec2_instance_type": model.LabelValue("t3.large"), + "__meta_ecs_ec2_instance_private_ip": model.LabelValue("10.0.1.75"), + "__meta_ecs_ec2_instance_public_ip": model.LabelValue("54.2.3.4"), + "__meta_ecs_public_ip": model.LabelValue("54.2.3.4"), + "__meta_ecs_tag_ec2_Name": model.LabelValue("mixed-host"), + "__meta_ecs_tag_ec2_Team": model.LabelValue("platform"), + }, + }, + }, + }, + }, + { + name: "EC2WithAwsvpcNetworking", + ecsData: &ecsDataStore{ + region: "us-west-2", + clusters: []ecsTypes.Cluster{ + { + ClusterName: strptr("ec2-awsvpc-cluster"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/ec2-awsvpc-cluster"), + Status: strptr("ACTIVE"), + }, + }, + services: []ecsTypes.Service{ + { + ServiceName: strptr("ec2-awsvpc-service"), + ServiceArn: strptr("arn:aws:ecs:us-west-2:123456789012:service/ec2-awsvpc-cluster/ec2-awsvpc-service"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/ec2-awsvpc-cluster"), + Status: strptr("ACTIVE"), + }, + }, + tasks: []ecsTypes.Task{ + { + TaskArn: strptr("arn:aws:ecs:us-west-2:123456789012:task/ec2-awsvpc-cluster/task-ec2-awsvpc"), + ClusterArn: strptr("arn:aws:ecs:us-west-2:123456789012:cluster/ec2-awsvpc-cluster"), + TaskDefinitionArn: strptr("arn:aws:ecs:us-west-2:123456789012:task-definition/ec2-awsvpc-task:1"), + Group: strptr("service:ec2-awsvpc-service"), + LaunchType: ecsTypes.LaunchTypeEc2, + LastStatus: strptr("RUNNING"), + DesiredStatus: strptr("RUNNING"), + HealthStatus: ecsTypes.HealthStatusHealthy, + AvailabilityZone: strptr("us-west-2c"), + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/ec2-awsvpc-cluster/def456"), + // Has BOTH ENI attachment AND container instance ARN - should use ENI + Attachments: []ecsTypes.Attachment{ + { + Type: strptr("ElasticNetworkInterface"), + Details: []ecsTypes.KeyValuePair{ + {Name: strptr("subnetId"), Value: strptr("subnet-99999")}, + {Name: strptr("privateIPv4Address"), Value: strptr("10.0.3.200")}, + {Name: strptr("networkInterfaceId"), Value: strptr("eni-ec2-awsvpc")}, + }, + }, + }, + }, + }, + eniPublicIPs: map[string]string{ + "eni-ec2-awsvpc": "52.3.4.5", + }, + // Container instance data - IP should NOT be used, but instance type SHOULD be used + containerInstances: []ecsTypes.ContainerInstance{ + { + ContainerInstanceArn: strptr("arn:aws:ecs:us-west-2:123456789012:container-instance/ec2-awsvpc-cluster/def456"), + Ec2InstanceId: strptr("i-ec2awsvpcinstance"), + Status: strptr("ACTIVE"), + }, + }, + ec2Instances: map[string]ec2InstanceInfo{ + "i-ec2awsvpcinstance": { + privateIP: "10.0.9.99", // This IP should NOT be used (ENI IP is used instead) + publicIP: "54.3.4.5", // This public IP SHOULD be exposed + subnetID: "subnet-wrong", // This subnet should NOT be used (ENI subnet is used instead) + instanceType: "c5.2xlarge", // This instance type SHOULD be used + tags: map[string]string{ + "Name": "ec2-awsvpc-host", + "Owner": "team-a", + }, + }, + }, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("10.0.3.200:80"), + "__meta_ecs_cluster": model.LabelValue("ec2-awsvpc-cluster"), + "__meta_ecs_cluster_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:cluster/ec2-awsvpc-cluster"), + "__meta_ecs_service": model.LabelValue("ec2-awsvpc-service"), + "__meta_ecs_service_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:service/ec2-awsvpc-cluster/ec2-awsvpc-service"), + "__meta_ecs_service_status": model.LabelValue("ACTIVE"), + "__meta_ecs_task_group": model.LabelValue("service:ec2-awsvpc-service"), + "__meta_ecs_task_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task/ec2-awsvpc-cluster/task-ec2-awsvpc"), + "__meta_ecs_task_definition": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:task-definition/ec2-awsvpc-task:1"), + "__meta_ecs_region": model.LabelValue("us-west-2"), + "__meta_ecs_availability_zone": model.LabelValue("us-west-2c"), + "__meta_ecs_ip_address": model.LabelValue("10.0.3.200"), + "__meta_ecs_subnet_id": model.LabelValue("subnet-99999"), + "__meta_ecs_launch_type": model.LabelValue("EC2"), + "__meta_ecs_desired_status": model.LabelValue("RUNNING"), + "__meta_ecs_last_status": model.LabelValue("RUNNING"), + "__meta_ecs_health_status": model.LabelValue("HEALTHY"), + "__meta_ecs_network_mode": model.LabelValue("awsvpc"), + "__meta_ecs_container_instance_arn": model.LabelValue("arn:aws:ecs:us-west-2:123456789012:container-instance/ec2-awsvpc-cluster/def456"), + "__meta_ecs_ec2_instance_id": model.LabelValue("i-ec2awsvpcinstance"), + "__meta_ecs_ec2_instance_type": model.LabelValue("c5.2xlarge"), + "__meta_ecs_ec2_instance_private_ip": model.LabelValue("10.0.9.99"), + "__meta_ecs_ec2_instance_public_ip": model.LabelValue("54.3.4.5"), + "__meta_ecs_public_ip": model.LabelValue("52.3.4.5"), + "__meta_ecs_tag_ec2_Name": model.LabelValue("ec2-awsvpc-host"), + "__meta_ecs_tag_ec2_Owner": model.LabelValue("team-a"), + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ecsClient := newMockECSClient(tt.ecsData) + ec2Client := newMockECSEC2Client(tt.ecsData.ec2Instances, tt.ecsData.eniPublicIPs) + + d := &ECSDiscovery{ + ecs: ecsClient, + ec2: ec2Client, + cfg: &ECSSDConfig{ + Region: tt.ecsData.region, + Port: 80, + RequestConcurrency: 1, + }, + } + + groups, err := d.refresh(ctx) + require.NoError(t, err) + if tt.name == "MixedNetworkingModes" { + // Use ElementsMatch for tests with multiple tasks as goroutines can affect order + require.Len(t, groups, len(tt.expected)) + require.Equal(t, tt.expected[0].Source, groups[0].Source) + require.ElementsMatch(t, tt.expected[0].Targets, groups[0].Targets) + } else { + require.Equal(t, tt.expected, groups) + } + }) + } +} + +// ECS client mock. +type mockECSClient struct { + ecsData ecsDataStore +} + +func newMockECSClient(ecsData *ecsDataStore) *mockECSClient { + client := mockECSClient{ + ecsData: *ecsData, + } + return &client +} + +func (m *mockECSClient) ListClusters(_ context.Context, _ *ecs.ListClustersInput, _ ...func(*ecs.Options)) (*ecs.ListClustersOutput, error) { + clusterArns := make([]string, 0, len(m.ecsData.clusters)) + for _, cluster := range m.ecsData.clusters { + clusterArns = append(clusterArns, *cluster.ClusterArn) + } + + return &ecs.ListClustersOutput{ + ClusterArns: clusterArns, + }, nil +} + +func (m *mockECSClient) DescribeClusters(_ context.Context, input *ecs.DescribeClustersInput, _ ...func(*ecs.Options)) (*ecs.DescribeClustersOutput, error) { + var clusters []ecsTypes.Cluster + for _, clusterArn := range input.Clusters { + for _, cluster := range m.ecsData.clusters { + if *cluster.ClusterArn == clusterArn { + clusters = append(clusters, cluster) + break + } + } + } + + return &ecs.DescribeClustersOutput{ + Clusters: clusters, + }, nil +} + +func (m *mockECSClient) ListServices(_ context.Context, input *ecs.ListServicesInput, _ ...func(*ecs.Options)) (*ecs.ListServicesOutput, error) { + var serviceArns []string + for _, service := range m.ecsData.services { + if *service.ClusterArn == *input.Cluster { + serviceArns = append(serviceArns, *service.ServiceArn) + } + } + + return &ecs.ListServicesOutput{ + ServiceArns: serviceArns, + }, nil +} + +func (m *mockECSClient) DescribeServices(_ context.Context, input *ecs.DescribeServicesInput, _ ...func(*ecs.Options)) (*ecs.DescribeServicesOutput, error) { + var services []ecsTypes.Service + for _, serviceArn := range input.Services { + for _, service := range m.ecsData.services { + if *service.ServiceArn == serviceArn && *service.ClusterArn == *input.Cluster { + services = append(services, service) + break + } + } + } + + return &ecs.DescribeServicesOutput{ + Services: services, + }, nil +} + +func (m *mockECSClient) ListTasks(_ context.Context, input *ecs.ListTasksInput, _ ...func(*ecs.Options)) (*ecs.ListTasksOutput, error) { + var taskArns []string + for _, task := range m.ecsData.tasks { + if *task.ClusterArn == *input.Cluster { + // If ServiceName is specified, filter by service + if input.ServiceName != nil { + expectedGroup := "service:" + *input.ServiceName + if task.Group != nil && *task.Group == expectedGroup { + taskArns = append(taskArns, *task.TaskArn) + } + } else { + taskArns = append(taskArns, *task.TaskArn) + } + } + } + + return &ecs.ListTasksOutput{ + TaskArns: taskArns, + }, nil +} + +func (m *mockECSClient) DescribeTasks(_ context.Context, input *ecs.DescribeTasksInput, _ ...func(*ecs.Options)) (*ecs.DescribeTasksOutput, error) { + var tasks []ecsTypes.Task + for _, taskArn := range input.Tasks { + for _, task := range m.ecsData.tasks { + if *task.TaskArn == taskArn && *task.ClusterArn == *input.Cluster { + tasks = append(tasks, task) + break + } + } + } + + return &ecs.DescribeTasksOutput{ + Tasks: tasks, + }, nil +} + +func (m *mockECSClient) DescribeContainerInstances(_ context.Context, input *ecs.DescribeContainerInstancesInput, _ ...func(*ecs.Options)) (*ecs.DescribeContainerInstancesOutput, error) { + var containerInstances []ecsTypes.ContainerInstance + for _, ciArn := range input.ContainerInstances { + for _, ci := range m.ecsData.containerInstances { + if *ci.ContainerInstanceArn == ciArn { + containerInstances = append(containerInstances, ci) + break + } + } + } + + return &ecs.DescribeContainerInstancesOutput{ + ContainerInstances: containerInstances, + }, nil +} + +// Mock EC2 client wrapper for ECS tests. +type mockECSEC2Client struct { + ec2Instances map[string]ec2InstanceInfo + eniPublicIPs map[string]string +} + +func newMockECSEC2Client(ec2Instances map[string]ec2InstanceInfo, eniPublicIPs map[string]string) *mockECSEC2Client { + return &mockECSEC2Client{ + ec2Instances: ec2Instances, + eniPublicIPs: eniPublicIPs, + } +} + +func (m *mockECSEC2Client) DescribeInstances(_ context.Context, input *ec2.DescribeInstancesInput, _ ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { + var reservations []ec2Types.Reservation + + for _, instanceID := range input.InstanceIds { + if info, ok := m.ec2Instances[instanceID]; ok { + instance := ec2Types.Instance{ + InstanceId: &instanceID, + PrivateIpAddress: &info.privateIP, + } + if info.publicIP != "" { + instance.PublicIpAddress = &info.publicIP + } + if info.subnetID != "" { + instance.SubnetId = &info.subnetID + } + if info.instanceType != "" { + instance.InstanceType = ec2Types.InstanceType(info.instanceType) + } + // Add tags + for tagKey, tagValue := range info.tags { + instance.Tags = append(instance.Tags, ec2Types.Tag{ + Key: &tagKey, + Value: &tagValue, + }) + } + reservation := ec2Types.Reservation{ + Instances: []ec2Types.Instance{instance}, + } + reservations = append(reservations, reservation) + } + } + + return &ec2.DescribeInstancesOutput{ + Reservations: reservations, + }, nil +} + +func (m *mockECSEC2Client) DescribeNetworkInterfaces(_ context.Context, input *ec2.DescribeNetworkInterfacesInput, _ ...func(*ec2.Options)) (*ec2.DescribeNetworkInterfacesOutput, error) { + var networkInterfaces []ec2Types.NetworkInterface + + for _, eniID := range input.NetworkInterfaceIds { + if publicIP, ok := m.eniPublicIPs[eniID]; ok { + eni := ec2Types.NetworkInterface{ + NetworkInterfaceId: &eniID, + } + if publicIP != "" { + eni.Association = &ec2Types.NetworkInterfaceAssociation{ + PublicIp: &publicIP, + } + } + networkInterfaces = append(networkInterfaces, eni) + } + } + + return &ec2.DescribeNetworkInterfacesOutput{ + NetworkInterfaces: networkInterfaces, + }, nil +} + +func TestIsStandaloneTask(t *testing.T) { + tests := []struct { + name string + task ecsTypes.Task + expected bool + }{ + { + name: "StandaloneTask", + task: ecsTypes.Task{ + Group: strptr("family:my-task-definition"), + }, + expected: true, + }, + { + name: "ServiceTask", + task: ecsTypes.Task{ + Group: strptr("service:my-service"), + }, + expected: false, + }, + { + name: "ServiceTaskWithColon", + task: ecsTypes.Task{ + Group: strptr("service:my:service:name"), + }, + expected: false, + }, + { + name: "NilGroup", + task: ecsTypes.Task{ + Group: nil, + }, + expected: false, + }, + { + name: "EmptyGroup", + task: ecsTypes.Task{ + Group: strptr(""), + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isStandaloneTask(tt.task) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestGetServiceNameFromTaskGroup(t *testing.T) { + tests := []struct { + name string + task ecsTypes.Task + expected string + }{ + { + name: "SimpleServiceName", + task: ecsTypes.Task{ + Group: strptr("service:my-service"), + }, + expected: "my-service", + }, + { + name: "ServiceNameWithHyphens", + task: ecsTypes.Task{ + Group: strptr("service:web-api-service"), + }, + expected: "web-api-service", + }, + { + name: "ServiceNameWithColons", + task: ecsTypes.Task{ + Group: strptr("service:my:service:name"), + }, + expected: "my", + }, + { + name: "FamilyGroup", + task: ecsTypes.Task{ + Group: strptr("family:my-task-def"), + }, + expected: "my-task-def", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getServiceNameFromTaskGroup(tt.task) + require.Equal(t, tt.expected, result) + }) + } +} diff --git a/discovery/aws/elasticache.go b/discovery/aws/elasticache.go new file mode 100644 index 0000000000..7ed598e294 --- /dev/null +++ b/discovery/aws/elasticache.go @@ -0,0 +1,907 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/elasticache" + "github.com/aws/aws-sdk-go-v2/service/elasticache/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/prometheus/common/promslog" + "golang.org/x/sync/errgroup" + + "github.com/prometheus/prometheus/discovery" + "github.com/prometheus/prometheus/discovery/refresh" + "github.com/prometheus/prometheus/discovery/targetgroup" + "github.com/prometheus/prometheus/util/strutil" +) + +const ( + elasticacheLabel = model.MetaLabelPrefix + "elasticache_" + elasticacheLabelDeploymentOption = elasticacheLabel + "deployment_option" + + // cache cluster. + elasticacheLabelCacheCluster = elasticacheLabel + "cache_cluster_" + elasticacheLabelCacheClusterARN = elasticacheLabelCacheCluster + "arn" + elasticacheLabelCacheClusterAtRestEncryptionEnabled = elasticacheLabelCacheCluster + "at_rest_encryption_enabled" + elasticacheLabelCacheClusterAuthTokenEnabled = elasticacheLabelCacheCluster + "auth_token_enabled" + elasticacheLabelCacheClusterAuthTokenLastModified = elasticacheLabelCacheCluster + "auth_token_last_modified" + elasticacheLabelCacheClusterAutoMinorVersionUpgrade = elasticacheLabelCacheCluster + "auto_minor_version_upgrade" + elasticacheLabelCacheClusterCreateTime = elasticacheLabelCacheCluster + "cache_cluster_create_time" + elasticacheLabelCacheClusterID = elasticacheLabelCacheCluster + "cache_cluster_id" + elasticacheLabelCacheClusterStatus = elasticacheLabelCacheCluster + "cache_cluster_status" + elasticacheLabelCacheClusterNodeType = elasticacheLabelCacheCluster + "cache_node_type" + elasticacheLabelCacheClusterParameterGroup = elasticacheLabelCacheCluster + "cache_parameter_group" + elasticacheLabelCacheClusterSubnetGroupName = elasticacheLabelCacheCluster + "cache_subnet_group_name" + elasticacheLabelCacheClusterClientDownloadLandingPage = elasticacheLabelCacheCluster + "client_download_landing_page" + elasticacheLabelCacheClusterEngine = elasticacheLabelCacheCluster + "engine" + elasticacheLabelCacheClusterEngineVersion = elasticacheLabelCacheCluster + "engine_version" + elasticacheLabelCacheClusterIPDiscovery = elasticacheLabelCacheCluster + "ip_discovery" + elasticacheLabelCacheClusterNetworkType = elasticacheLabelCacheCluster + "network_type" + elasticacheLabelCacheClusterNumCacheNodes = elasticacheLabelCacheCluster + "num_cache_nodes" + elasticacheLabelCacheClusterPreferredAvailabilityZone = elasticacheLabelCacheCluster + "preferred_availability_zone" + elasticacheLabelCacheClusterPreferredMaintenanceWindow = elasticacheLabelCacheCluster + "preferred_maintenance_window" + elasticacheLabelCacheClusterPreferredOutpostARN = elasticacheLabelCacheCluster + "preferred_outpost_arn" + elasticacheLabelCacheClusterReplicationGroupID = elasticacheLabelCacheCluster + "replication_group_id" + elasticacheLabelCacheClusterReplicationGroupLogDeliveryEnabled = elasticacheLabelCacheCluster + "replication_group_log_delivery_enabled" + elasticacheLabelCacheClusterSnapshotRetentionLimit = elasticacheLabelCacheCluster + "snapshot_retention_limit" + elasticacheLabelCacheClusterSnapshotWindow = elasticacheLabelCacheCluster + "snapshot_window" + elasticacheLabelCacheClusterTransitEncryptionEnabled = elasticacheLabelCacheCluster + "transit_encryption_enabled" + elasticacheLabelCacheClusterTransitEncryptionMode = elasticacheLabelCacheCluster + "transit_encryption_mode" + + // configuration endpoint. + elasticacheLabelCacheClusterConfigurationEndpoint = elasticacheLabelCacheCluster + "configuration_endpoint_" + elasticacheLabelCacheClusterConfigurationEndpointAddress = elasticacheLabelCacheClusterConfigurationEndpoint + "address" + elasticacheLabelCacheClusterConfigurationEndpointPort = elasticacheLabelCacheClusterConfigurationEndpoint + "port" + + // notification. + elasticacheLabelCacheClusterNotification = elasticacheLabelCacheCluster + "notification_" + elasticacheLabelCacheClusterNotificationTopicARN = elasticacheLabelCacheClusterNotification + "topic_arn" + elasticacheLabelCacheClusterNotificationTopicStatus = elasticacheLabelCacheClusterNotification + "topic_status" + + // log delivery configuration (slice - use with index). + elasticacheLabelCacheClusterLogDeliveryConfiguration = elasticacheLabelCacheCluster + "log_delivery_configuration_" + elasticacheLabelCacheClusterLogDeliveryConfigurationDestinationType = elasticacheLabelCacheClusterLogDeliveryConfiguration + "destination_type" + elasticacheLabelCacheClusterLogDeliveryConfigurationLogFormat = elasticacheLabelCacheClusterLogDeliveryConfiguration + "log_format" + elasticacheLabelCacheClusterLogDeliveryConfigurationLogType = elasticacheLabelCacheClusterLogDeliveryConfiguration + "log_type" + elasticacheLabelCacheClusterLogDeliveryConfigurationStatus = elasticacheLabelCacheClusterLogDeliveryConfiguration + "status" + elasticacheLabelCacheClusterLogDeliveryConfigurationMessage = elasticacheLabelCacheClusterLogDeliveryConfiguration + "message" + elasticacheLabelCacheClusterLogDeliveryConfigurationLogGroup = elasticacheLabelCacheClusterLogDeliveryConfiguration + "log_group" + elasticacheLabelCacheClusterLogDeliveryConfigurationDeliveryStream = elasticacheLabelCacheClusterLogDeliveryConfiguration + "delivery_stream" + + // pending modified values. + elasticacheLabelCacheClusterPendingModifiedValues = elasticacheLabelCacheCluster + "pending_modified_values_" + elasticacheLabelCacheClusterPendingModifiedValuesAuthTokenStatus = elasticacheLabelCacheClusterPendingModifiedValues + "auth_token_status" + elasticacheLabelCacheClusterPendingModifiedValuesCacheNodeType = elasticacheLabelCacheClusterPendingModifiedValues + "cache_node_type" + elasticacheLabelCacheClusterPendingModifiedValuesEngineVersion = elasticacheLabelCacheClusterPendingModifiedValues + "engine_version" + elasticacheLabelCacheClusterPendingModifiedValuesNumCacheNodes = elasticacheLabelCacheClusterPendingModifiedValues + "num_cache_nodes" + elasticacheLabelCacheClusterPendingModifiedValuesTransitEncryptionEnabled = elasticacheLabelCacheClusterPendingModifiedValues + "transit_encryption_enabled" + elasticacheLabelCacheClusterPendingModifiedValuesTransitEncryptionMode = elasticacheLabelCacheClusterPendingModifiedValues + "transit_encryption_mode" + elasticacheLabelCacheClusterPendingModifiedValuesCacheNodeIDsToRemove = elasticacheLabelCacheClusterPendingModifiedValues + "cache_node_ids_to_remove" + + // security group membership (slice - use with index). + elasticacheLabelCacheClusterSecurityGroupMembership = elasticacheLabelCacheCluster + "security_group_membership_" + elasticacheLabelCacheClusterSecurityGroupMembershipID = elasticacheLabelCacheClusterSecurityGroupMembership + "id" + elasticacheLabelCacheClusterSecurityGroupMembershipStatus = elasticacheLabelCacheClusterSecurityGroupMembership + "status" + + // tags - create one label per tag key, with the format: elasticache_cache_cluster_tag_. + elasticacheLabelCacheClusterTag = elasticacheLabelCacheCluster + "tag_" + + // node. + elasticacheLabelCacheClusterNode = elasticacheLabelCacheCluster + "node_" + elasticacheLabelCacheClusterNodeCreateTime = elasticacheLabelCacheClusterNode + "create_time" + elasticacheLabelCacheClusterNodeID = elasticacheLabelCacheClusterNode + "id" + elasticacheLabelCacheClusterNodeStatus = elasticacheLabelCacheClusterNode + "status" + elasticacheLabelCacheClusterNodeAZ = elasticacheLabelCacheClusterNode + "availability_zone" + elasticacheLabelCacheClusterNodeCustomerOutpostARN = elasticacheLabelCacheClusterNode + "customer_outpost_arn" + elasticacheLabelCacheClusterNodeSourceCacheNodeID = elasticacheLabelCacheClusterNode + "source_cache_node_id" + elasticacheLabelCacheClusterNodeParameterGroupStatus = elasticacheLabelCacheClusterNode + "parameter_group_status" + + // endpoint. + elasticacheLabelCacheClusterNodeEndpoint = elasticacheLabelCacheClusterNode + "endpoint_" + elasticacheLabelCacheClusterNodeEndpointAddress = elasticacheLabelCacheClusterNodeEndpoint + "address" + elasticacheLabelCacheClusterNodeEndpointPort = elasticacheLabelCacheClusterNodeEndpoint + "port" + + // serverless cache. + elasticacheLabelServerlessCache = elasticacheLabel + "serverless_cache_" + elasticacheLabelServerlessCacheARN = elasticacheLabelServerlessCache + "arn" + elasticacheLabelServerlessCacheName = elasticacheLabelServerlessCache + "name" + elasticacheLabelServerlessCacheCreateTime = elasticacheLabelServerlessCache + "create_time" + elasticacheLabelServerlessCacheDescription = elasticacheLabelServerlessCache + "description" + elasticacheLabelServerlessCacheEngine = elasticacheLabelServerlessCache + "engine" + elasticacheLabelServerlessCacheFullEngineVersion = elasticacheLabelServerlessCache + "full_engine_version" + elasticacheLabelServerlessCacheMajorEngineVersion = elasticacheLabelServerlessCache + "major_engine_version" + elasticacheLabelServerlessCacheStatus = elasticacheLabelServerlessCache + "status" + elasticacheLabelServerlessCacheKmsKeyID = elasticacheLabelServerlessCache + "kms_key_id" + elasticacheLabelServerlessCacheUserGroupID = elasticacheLabelServerlessCache + "user_group_id" + elasticacheLabelServerlessCacheDailySnapshotTime = elasticacheLabelServerlessCache + "daily_snapshot_time" + elasticacheLabelServerlessCacheSnapshotRetentionLimit = elasticacheLabelServerlessCache + "snapshot_retention_limit" + + // endpoint. + elasticacheLabelServerlessCacheEndpoint = elasticacheLabelServerlessCache + "endpoint_" + elasticacheLabelServerlessCacheEndpointAddress = elasticacheLabelServerlessCacheEndpoint + "address" + elasticacheLabelServerlessCacheEndpointPort = elasticacheLabelServerlessCacheEndpoint + "port" + elasticacheLabelServerlessCacheReaderEndpointAddress = elasticacheLabelServerlessCacheEndpoint + "reader_address" + elasticacheLabelServerlessCacheReaderEndpointPort = elasticacheLabelServerlessCacheEndpoint + "reader_port" + + // security group membership (slice - use with index). + elasticacheLabelServerlessCacheSecurityGroupID = elasticacheLabelServerlessCache + "security_group_id" + + // Subnet group membership (slice - use with index). + elasticacheLabelServerlessCacheSubnetID = elasticacheLabelServerlessCache + "subnet_id" + + // cache usage limits. + elasticacheLabelServerlessCacheCacheUsageLimit = elasticacheLabelServerlessCache + "cache_usage_limit_" + elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorage = elasticacheLabelServerlessCacheCacheUsageLimit + "data_storage" + elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorageMaximum = elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorage + "maximum" + elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorageMinimum = elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorage + "minimum" + elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorageUnit = elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorage + "unit" + elasticacheLabelServerlessCacheCacheUsageLimitECPUPerSecond = elasticacheLabelServerlessCacheCacheUsageLimit + "ecpu_per_second" + elasticacheLabelServerlessCacheCacheUsageLimitECPUPerSecondMaximum = elasticacheLabelServerlessCacheCacheUsageLimitECPUPerSecond + "maximum" + elasticacheLabelServerlessCacheCacheUsageLimitECPUPerSecondMinimum = elasticacheLabelServerlessCacheCacheUsageLimitECPUPerSecond + "minimum" + + // tags - create one label per tag key, with the format: elasticache_serverless_cache_tag_. + elasticacheLabelServerlessCacheTag = elasticacheLabelServerlessCache + "tag_" +) + +// DefaultElasticacheSDConfig is the default Elasticache SD configuration. +var DefaultElasticacheSDConfig = ElasticacheSDConfig{ + Port: 80, + RefreshInterval: model.Duration(60 * time.Second), + RequestConcurrency: 10, + HTTPClientConfig: config.DefaultHTTPClientConfig, +} + +func init() { + discovery.RegisterConfig(&ElasticacheSDConfig{}) +} + +// ElasticacheSDConfig is the configuration for Elasticache based service discovery. +type ElasticacheSDConfig struct { + Region string `yaml:"region"` + Endpoint string `yaml:"endpoint"` + AccessKey string `yaml:"access_key,omitempty"` + SecretKey config.Secret `yaml:"secret_key,omitempty"` + Profile string `yaml:"profile,omitempty"` + RoleARN string `yaml:"role_arn,omitempty"` + Clusters []string `yaml:"clusters,omitempty"` + Port int `yaml:"port"` + RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"` + + // RequestConcurrency controls the maximum number of concurrent Elasticache API requests. + RequestConcurrency int `yaml:"request_concurrency,omitempty"` + + HTTPClientConfig config.HTTPClientConfig `yaml:",inline"` +} + +// NewDiscovererMetrics implements discovery.Config. +func (*ElasticacheSDConfig) NewDiscovererMetrics(_ prometheus.Registerer, rmi discovery.RefreshMetricsInstantiator) discovery.DiscovererMetrics { + return &elasticacheMetrics{ + refreshMetrics: rmi, + } +} + +// Name returns the name of the Elasticache Config. +func (*ElasticacheSDConfig) Name() string { return "elasticache" } + +// NewDiscoverer returns a Discoverer for the Elasticache Config. +func (c *ElasticacheSDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewElasticacheDiscovery(c, opts) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for the Elasticache Config. +func (c *ElasticacheSDConfig) UnmarshalYAML(unmarshal func(any) error) error { + *c = DefaultElasticacheSDConfig + type plain ElasticacheSDConfig + err := unmarshal((*plain)(c)) + if err != nil { + return err + } + + c.Region, err = loadRegion(context.Background(), c.Region) + if err != nil { + return fmt.Errorf("could not determine AWS region: %w", err) + } + + return c.HTTPClientConfig.Validate() +} + +type elasticacheClient interface { + DescribeServerlessCaches(ctx context.Context, params *elasticache.DescribeServerlessCachesInput, optFns ...func(*elasticache.Options)) (*elasticache.DescribeServerlessCachesOutput, error) + DescribeCacheClusters(ctx context.Context, params *elasticache.DescribeCacheClustersInput, optFns ...func(*elasticache.Options)) (*elasticache.DescribeCacheClustersOutput, error) + ListTagsForResource(ctx context.Context, params *elasticache.ListTagsForResourceInput, optFns ...func(*elasticache.Options)) (*elasticache.ListTagsForResourceOutput, error) +} + +// ElasticacheDiscovery periodically performs Elasticache-SD requests. +// It implements the Discoverer interface. +type ElasticacheDiscovery struct { + *refresh.Discovery + logger *slog.Logger + cfg *ElasticacheSDConfig + elasticacheClient elasticacheClient +} + +// NewElasticacheDiscovery returns a new ElasticacheDiscovery which periodically refreshes its targets. +func NewElasticacheDiscovery(conf *ElasticacheSDConfig, opts discovery.DiscovererOptions) (*ElasticacheDiscovery, error) { + m, ok := opts.Metrics.(*elasticacheMetrics) + if !ok { + return nil, errors.New("invalid discovery metrics type") + } + + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() + } + d := &ElasticacheDiscovery{ + logger: opts.Logger, + cfg: conf, + } + d.Discovery = refresh.NewDiscovery( + refresh.Options{ + Logger: opts.Logger, + Mech: "elasticache", + Interval: time.Duration(d.cfg.RefreshInterval), + RefreshF: d.refresh, + MetricsInstantiator: m.refreshMetrics, + }, + ) + return d, nil +} + +func (d *ElasticacheDiscovery) initElasticacheClient(ctx context.Context) error { + if d.elasticacheClient != nil { + return nil + } + + if d.cfg.Region == "" { + return errors.New("region must be set for Elasticache service discovery") + } + + // Build the HTTP client from the provided HTTPClientConfig. + client, err := config.NewClientFromConfig(d.cfg.HTTPClientConfig, "elasticache_sd") + if err != nil { + return err + } + + // Build the AWS config with the provided region. + var configOptions []func(*awsConfig.LoadOptions) error + configOptions = append(configOptions, awsConfig.WithRegion(d.cfg.Region)) + configOptions = append(configOptions, awsConfig.WithHTTPClient(client)) + + // Only set static credentials if both access key and secret key are provided + // Otherwise, let AWS SDK use its default credential chain + if d.cfg.AccessKey != "" && d.cfg.SecretKey != "" { + credProvider := credentials.NewStaticCredentialsProvider(d.cfg.AccessKey, string(d.cfg.SecretKey), "") + configOptions = append(configOptions, awsConfig.WithCredentialsProvider(credProvider)) + } + + if d.cfg.Profile != "" { + configOptions = append(configOptions, awsConfig.WithSharedConfigProfile(d.cfg.Profile)) + } + + cfg, err := awsConfig.LoadDefaultConfig(ctx, configOptions...) + if err != nil { + d.logger.Error("Failed to create AWS config", "error", err) + return fmt.Errorf("could not create aws config: %w", err) + } + + // If the role ARN is set, assume the role to get credentials and set the credentials provider in the config. + if d.cfg.RoleARN != "" { + assumeProvider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), d.cfg.RoleARN) + cfg.Credentials = aws.NewCredentialsCache(assumeProvider) + } + + d.elasticacheClient = elasticache.NewFromConfig(cfg, func(options *elasticache.Options) { + if d.cfg.Endpoint != "" { + options.BaseEndpoint = &d.cfg.Endpoint + } + options.HTTPClient = client + }) + + // Test credentials by making a simple API call + testCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + _, err = d.elasticacheClient.DescribeCacheClusters(testCtx, &elasticache.DescribeCacheClustersInput{}) + if err != nil { + d.logger.Error("Failed to test Elasticache credentials", "error", err) + return fmt.Errorf("elasticache credential test failed: %w", err) + } + + return nil +} + +// describeServerlessCaches calls DescribeServerlessCaches API for the given cache IDs (or all caches if no IDs are provided) and returns the list of serverless caches. +func (d *ElasticacheDiscovery) describeServerlessCaches(ctx context.Context, caches []string) ([]types.ServerlessCache, error) { + mu := &sync.Mutex{} + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + var serverlessCaches []types.ServerlessCache + if len(caches) == 0 { + errg.Go(func() error { + var nextToken *string + for { + output, err := d.elasticacheClient.DescribeServerlessCaches(ectx, &elasticache.DescribeServerlessCachesInput{ + MaxResults: aws.Int32(50), + NextToken: nextToken, + }) + if err != nil { + return fmt.Errorf("failed to describe serverless caches: %w", err) + } + mu.Lock() + serverlessCaches = append(serverlessCaches, output.ServerlessCaches...) + mu.Unlock() + if output.NextToken == nil { + break + } + nextToken = output.NextToken + } + return nil + }) + } else { + for _, cacheID := range caches { + errg.Go(func() error { + output, err := d.elasticacheClient.DescribeServerlessCaches(ectx, &elasticache.DescribeServerlessCachesInput{ + MaxResults: aws.Int32(50), + NextToken: nil, + ServerlessCacheName: aws.String(cacheID), + }) + if err != nil { + return fmt.Errorf("failed to describe serverless cache %s: %w", cacheID, err) + } + mu.Lock() + serverlessCaches = append(serverlessCaches, output.ServerlessCaches...) + mu.Unlock() + return nil + }) + } + } + + return serverlessCaches, errg.Wait() +} + +// describeCacheClusters calls DescribeCacheClusters API for the given cache cluster IDs (or all cache clusters if no IDs are provided) and returns the list of cache clusters. +func (d *ElasticacheDiscovery) describeCacheClusters(ctx context.Context, caches []string) ([]types.CacheCluster, error) { + mu := &sync.Mutex{} + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + showCacheClustersNotInReplicationGroupsBools := []bool{false, true} + var cacheClusters []types.CacheCluster + if len(caches) == 0 { + for _, showCacheClustersNotInReplicationGroupsBool := range showCacheClustersNotInReplicationGroupsBools { + errg.Go(func() error { + var nextToken *string + for { + output, err := d.elasticacheClient.DescribeCacheClusters(ectx, &elasticache.DescribeCacheClustersInput{ + MaxRecords: aws.Int32(100), + Marker: nextToken, + ShowCacheNodeInfo: aws.Bool(true), + ShowCacheClustersNotInReplicationGroups: aws.Bool(showCacheClustersNotInReplicationGroupsBool), + }) + if err != nil { + return fmt.Errorf("failed to describe cache clusters: %w", err) + } + mu.Lock() + cacheClusters = append(cacheClusters, output.CacheClusters...) + mu.Unlock() + if output.Marker == nil { + break + } + nextToken = output.Marker + } + return nil + }) + } + } else { + for _, cacheID := range caches { + for _, showCacheClustersNotInReplicationGroupsBool := range showCacheClustersNotInReplicationGroupsBools { + errg.Go(func() error { + output, err := d.elasticacheClient.DescribeCacheClusters(ectx, &elasticache.DescribeCacheClustersInput{ + MaxRecords: aws.Int32(100), + Marker: nil, + ShowCacheNodeInfo: aws.Bool(true), + ShowCacheClustersNotInReplicationGroups: aws.Bool(showCacheClustersNotInReplicationGroupsBool), + CacheClusterId: aws.String(cacheID), + }) + if err != nil { + return fmt.Errorf("failed to describe cache cluster %s: %w", cacheID, err) + } + mu.Lock() + cacheClusters = append(cacheClusters, output.CacheClusters...) + mu.Unlock() + return nil + }) + } + } + } + + return cacheClusters, errg.Wait() +} + +// listTagsForResource calls ListTagsForResource API for the given resource ARNs and returns a map of resource ARN to list of tags. +func (d *ElasticacheDiscovery) listTagsForResource(ctx context.Context, resourceARNs []string) (map[string][]types.Tag, error) { + mu := &sync.Mutex{} + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + tagsByResourceARN := make(map[string][]types.Tag) + for _, resourceARN := range resourceARNs { + errg.Go(func() error { + output, err := d.elasticacheClient.ListTagsForResource(ectx, &elasticache.ListTagsForResourceInput{ + ResourceName: aws.String(resourceARN), + }) + if err != nil { + return fmt.Errorf("failed to list tags for resource %s: %w", resourceARN, err) + } + mu.Lock() + tagsByResourceARN[resourceARN] = output.TagList + mu.Unlock() + return nil + }) + } + return tagsByResourceARN, errg.Wait() +} + +func (d *ElasticacheDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { + err := d.initElasticacheClient(ctx) + if err != nil { + return nil, err + } + + var clusters []string + clustersMu := sync.Mutex{} + serverlessCacheIDs, cacheClusterIDs := splitCacheDeploymentOptions(d.cfg.Clusters) + + clusterErrg, clusterCtx := errgroup.WithContext(ctx) + clusterErrg.Go(func() error { + caches, err := d.describeServerlessCaches(clusterCtx, serverlessCacheIDs) + if err != nil { + return fmt.Errorf("failed to describe serverless caches: %w", err) + } + for _, cache := range caches { + clustersMu.Lock() + clusters = append(clusters, *cache.ARN) + clustersMu.Unlock() + } + return nil + }) + + clusterErrg.Go(func() error { + cacheClusters, err := d.describeCacheClusters(clusterCtx, cacheClusterIDs) + if err != nil { + return fmt.Errorf("failed to describe cache clusters: %w", err) + } + for _, cluster := range cacheClusters { + clustersMu.Lock() + clusters = append(clusters, *cluster.ARN) + clustersMu.Unlock() + } + return nil + }) + + if err := clusterErrg.Wait(); err != nil { + return nil, err + } + + tagsByResourceARN, err := d.listTagsForResource(ctx, clusters) + if err != nil { + return nil, fmt.Errorf("failed to list tags for resources: %w", err) + } + + tg := &targetgroup.Group{ + Source: d.cfg.Region, + } + + errg, ectx := errgroup.WithContext(ctx) + errg.Go(func() error { + caches, err := d.describeServerlessCaches(ectx, serverlessCacheIDs) + if err != nil { + return fmt.Errorf("failed to describe serverless caches: %w", err) + } + for _, cache := range caches { + addServerlessCacheTargets(tg, &cache, tagsByResourceARN[*cache.ARN]) + } + return nil + }) + + errg.Go(func() error { + cacheClusters, err := d.describeCacheClusters(ectx, cacheClusterIDs) + if err != nil { + return fmt.Errorf("failed to describe cache clusters: %w", err) + } + for _, cluster := range cacheClusters { + addCacheClusterTargets(tg, &cluster, tagsByResourceARN[*cluster.ARN]) + } + return nil + }) + + if err := errg.Wait(); err != nil { + return nil, err + } + + return []*targetgroup.Group{tg}, nil +} + +// splitCacheTypes takes a list of cache ARNs and splits them into serverless cache IDs and cache cluster IDs based on their format. +// Serverless caches are in the format arn:aws:elasticache:::serverlesscache: +// Cache clusters are in the format arn:aws:elasticache:::replicationgroup:. +func splitCacheDeploymentOptions(caches []string) (serverlessCacheIDs, cacheClusterIDs []string) { + for _, cacheARN := range caches { + if len(cacheARN) == 0 { + continue + } + parts := strings.Split(cacheARN, ":") + if len(parts) < 6 { + continue + } + resourceType := parts[5] + resourceID := parts[6] + switch resourceType { + case "serverlesscache": + serverlessCacheIDs = append(serverlessCacheIDs, resourceID) + case "replicationgroup": + cacheClusterIDs = append(cacheClusterIDs, resourceID) + default: + continue + } + } + return serverlessCacheIDs, cacheClusterIDs +} + +// addServerlessCacheTargets adds targets for a serverless cache to the target group. +func addServerlessCacheTargets(tg *targetgroup.Group, cache *types.ServerlessCache, tags []types.Tag) { + labels := model.LabelSet{ + elasticacheLabelDeploymentOption: model.LabelValue("serverless"), + elasticacheLabelServerlessCacheARN: model.LabelValue(*cache.ARN), + elasticacheLabelServerlessCacheName: model.LabelValue(*cache.ServerlessCacheName), + elasticacheLabelServerlessCacheStatus: model.LabelValue(*cache.Status), + elasticacheLabelServerlessCacheEngine: model.LabelValue(*cache.Engine), + elasticacheLabelServerlessCacheFullEngineVersion: model.LabelValue(*cache.FullEngineVersion), + elasticacheLabelServerlessCacheMajorEngineVersion: model.LabelValue(*cache.MajorEngineVersion), + } + + if cache.Description != nil { + labels[elasticacheLabelServerlessCacheDescription] = model.LabelValue(*cache.Description) + } + + if cache.CreateTime != nil { + labels[elasticacheLabelServerlessCacheCreateTime] = model.LabelValue(cache.CreateTime.Format(time.RFC3339)) + } + + if cache.KmsKeyId != nil { + labels[elasticacheLabelServerlessCacheKmsKeyID] = model.LabelValue(*cache.KmsKeyId) + } + + if cache.UserGroupId != nil { + labels[elasticacheLabelServerlessCacheUserGroupID] = model.LabelValue(*cache.UserGroupId) + } + + if cache.DailySnapshotTime != nil { + labels[elasticacheLabelServerlessCacheDailySnapshotTime] = model.LabelValue(*cache.DailySnapshotTime) + } + + if cache.SnapshotRetentionLimit != nil { + labels[elasticacheLabelServerlessCacheSnapshotRetentionLimit] = model.LabelValue(strconv.Itoa(int(*cache.SnapshotRetentionLimit))) + } + + if cache.Endpoint != nil { + if cache.Endpoint.Address != nil { + labels[elasticacheLabelServerlessCacheEndpointAddress] = model.LabelValue(*cache.Endpoint.Address) + } + if cache.Endpoint.Port != nil { + labels[elasticacheLabelServerlessCacheEndpointPort] = model.LabelValue(strconv.Itoa(int(*cache.Endpoint.Port))) + } + } + + if cache.ReaderEndpoint != nil { + if cache.ReaderEndpoint.Address != nil { + labels[elasticacheLabelServerlessCacheReaderEndpointAddress] = model.LabelValue(*cache.ReaderEndpoint.Address) + } + if cache.ReaderEndpoint.Port != nil { + labels[elasticacheLabelServerlessCacheReaderEndpointPort] = model.LabelValue(strconv.Itoa(int(*cache.ReaderEndpoint.Port))) + } + } + + for i, sgID := range cache.SecurityGroupIds { + labels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelServerlessCacheSecurityGroupID, i))] = model.LabelValue(sgID) + } + + for i, subnetID := range cache.SubnetIds { + labels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelServerlessCacheSubnetID, i))] = model.LabelValue(subnetID) + } + + if cache.CacheUsageLimits != nil { + if cache.CacheUsageLimits.DataStorage != nil { + if cache.CacheUsageLimits.DataStorage.Maximum != nil { + labels[elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorageMaximum] = model.LabelValue(strconv.Itoa(int(*cache.CacheUsageLimits.DataStorage.Maximum))) + } + if cache.CacheUsageLimits.DataStorage.Minimum != nil { + labels[elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorageMinimum] = model.LabelValue(strconv.Itoa(int(*cache.CacheUsageLimits.DataStorage.Minimum))) + } + labels[elasticacheLabelServerlessCacheCacheUsageLimitCacheDataStorageUnit] = model.LabelValue(cache.CacheUsageLimits.DataStorage.Unit) + } + if cache.CacheUsageLimits.ECPUPerSecond != nil { + if cache.CacheUsageLimits.ECPUPerSecond.Maximum != nil { + labels[elasticacheLabelServerlessCacheCacheUsageLimitECPUPerSecondMaximum] = model.LabelValue(strconv.Itoa(int(*cache.CacheUsageLimits.ECPUPerSecond.Maximum))) + } + if cache.CacheUsageLimits.ECPUPerSecond.Minimum != nil { + labels[elasticacheLabelServerlessCacheCacheUsageLimitECPUPerSecondMinimum] = model.LabelValue(strconv.Itoa(int(*cache.CacheUsageLimits.ECPUPerSecond.Minimum))) + } + } + } + + for _, tag := range tags { + if tag.Key != nil && tag.Value != nil { + labels[model.LabelName(elasticacheLabelServerlessCacheTag+strutil.SanitizeLabelName(*tag.Key))] = model.LabelValue(*tag.Value) + } + } + + // Set the address label using the endpoint + if cache.Endpoint != nil && cache.Endpoint.Address != nil && cache.Endpoint.Port != nil { + labels[model.AddressLabel] = model.LabelValue(net.JoinHostPort(*cache.Endpoint.Address, strconv.Itoa(int(*cache.Endpoint.Port)))) + } + + tg.Targets = append(tg.Targets, labels) +} + +// addCacheClusterTargets adds targets for a cache cluster to the target group. +// Creates one target per cache node for individual scraping. +func addCacheClusterTargets(tg *targetgroup.Group, cluster *types.CacheCluster, tags []types.Tag) { + // Build common labels that apply to all nodes in this cluster + commonLabels := model.LabelSet{ + elasticacheLabelDeploymentOption: model.LabelValue("node"), + elasticacheLabelCacheClusterARN: model.LabelValue(*cluster.ARN), + elasticacheLabelCacheClusterID: model.LabelValue(*cluster.CacheClusterId), + elasticacheLabelCacheClusterStatus: model.LabelValue(*cluster.CacheClusterStatus), + } + + if cluster.AtRestEncryptionEnabled != nil { + commonLabels[elasticacheLabelCacheClusterAtRestEncryptionEnabled] = model.LabelValue(strconv.FormatBool(*cluster.AtRestEncryptionEnabled)) + } + + if cluster.AuthTokenEnabled != nil { + commonLabels[elasticacheLabelCacheClusterAuthTokenEnabled] = model.LabelValue(strconv.FormatBool(*cluster.AuthTokenEnabled)) + } + + if cluster.AuthTokenLastModifiedDate != nil { + commonLabels[elasticacheLabelCacheClusterAuthTokenLastModified] = model.LabelValue(cluster.AuthTokenLastModifiedDate.Format(time.RFC3339)) + } + + if cluster.AutoMinorVersionUpgrade != nil { + commonLabels[elasticacheLabelCacheClusterAutoMinorVersionUpgrade] = model.LabelValue(strconv.FormatBool(*cluster.AutoMinorVersionUpgrade)) + } + + if cluster.CacheClusterCreateTime != nil { + commonLabels[elasticacheLabelCacheClusterCreateTime] = model.LabelValue(cluster.CacheClusterCreateTime.Format(time.RFC3339)) + } + + if cluster.CacheNodeType != nil { + commonLabels[elasticacheLabelCacheClusterNodeType] = model.LabelValue(*cluster.CacheNodeType) + } + + if cluster.CacheParameterGroup != nil && cluster.CacheParameterGroup.CacheParameterGroupName != nil { + commonLabels[elasticacheLabelCacheClusterParameterGroup] = model.LabelValue(*cluster.CacheParameterGroup.CacheParameterGroupName) + } + + if cluster.CacheSubnetGroupName != nil { + commonLabels[elasticacheLabelCacheClusterSubnetGroupName] = model.LabelValue(*cluster.CacheSubnetGroupName) + } + + if cluster.ClientDownloadLandingPage != nil { + commonLabels[elasticacheLabelCacheClusterClientDownloadLandingPage] = model.LabelValue(*cluster.ClientDownloadLandingPage) + } + + if cluster.ConfigurationEndpoint != nil { + if cluster.ConfigurationEndpoint.Address != nil { + commonLabels[elasticacheLabelCacheClusterConfigurationEndpointAddress] = model.LabelValue(*cluster.ConfigurationEndpoint.Address) + } + if cluster.ConfigurationEndpoint.Port != nil { + commonLabels[elasticacheLabelCacheClusterConfigurationEndpointPort] = model.LabelValue(strconv.Itoa(int(*cluster.ConfigurationEndpoint.Port))) + } + } + + if cluster.Engine != nil { + commonLabels[elasticacheLabelCacheClusterEngine] = model.LabelValue(*cluster.Engine) + } + + if cluster.EngineVersion != nil { + commonLabels[elasticacheLabelCacheClusterEngineVersion] = model.LabelValue(*cluster.EngineVersion) + } + + if len(cluster.IpDiscovery) > 0 { + commonLabels[elasticacheLabelCacheClusterIPDiscovery] = model.LabelValue(cluster.IpDiscovery) + } + + if len(cluster.NetworkType) > 0 { + commonLabels[elasticacheLabelCacheClusterNetworkType] = model.LabelValue(cluster.NetworkType) + } + + if cluster.NotificationConfiguration != nil { + if cluster.NotificationConfiguration.TopicArn != nil { + commonLabels[elasticacheLabelCacheClusterNotificationTopicARN] = model.LabelValue(*cluster.NotificationConfiguration.TopicArn) + } + if cluster.NotificationConfiguration.TopicStatus != nil { + commonLabels[elasticacheLabelCacheClusterNotificationTopicStatus] = model.LabelValue(*cluster.NotificationConfiguration.TopicStatus) + } + } + + if cluster.NumCacheNodes != nil { + commonLabels[elasticacheLabelCacheClusterNumCacheNodes] = model.LabelValue(strconv.Itoa(int(*cluster.NumCacheNodes))) + } + + if cluster.PreferredAvailabilityZone != nil { + commonLabels[elasticacheLabelCacheClusterPreferredAvailabilityZone] = model.LabelValue(*cluster.PreferredAvailabilityZone) + } + + if cluster.PreferredMaintenanceWindow != nil { + commonLabels[elasticacheLabelCacheClusterPreferredMaintenanceWindow] = model.LabelValue(*cluster.PreferredMaintenanceWindow) + } + + if cluster.PreferredOutpostArn != nil { + commonLabels[elasticacheLabelCacheClusterPreferredOutpostARN] = model.LabelValue(*cluster.PreferredOutpostArn) + } + + if cluster.ReplicationGroupId != nil { + commonLabels[elasticacheLabelCacheClusterReplicationGroupID] = model.LabelValue(*cluster.ReplicationGroupId) + } + + if cluster.ReplicationGroupLogDeliveryEnabled != nil { + commonLabels[elasticacheLabelCacheClusterReplicationGroupLogDeliveryEnabled] = model.LabelValue(strconv.FormatBool(*cluster.ReplicationGroupLogDeliveryEnabled)) + } + + if cluster.SnapshotRetentionLimit != nil { + commonLabels[elasticacheLabelCacheClusterSnapshotRetentionLimit] = model.LabelValue(strconv.Itoa(int(*cluster.SnapshotRetentionLimit))) + } + + if cluster.SnapshotWindow != nil { + commonLabels[elasticacheLabelCacheClusterSnapshotWindow] = model.LabelValue(*cluster.SnapshotWindow) + } + + if cluster.TransitEncryptionEnabled != nil { + commonLabels[elasticacheLabelCacheClusterTransitEncryptionEnabled] = model.LabelValue(strconv.FormatBool(*cluster.TransitEncryptionEnabled)) + } + + if len(cluster.TransitEncryptionMode) > 0 { + commonLabels[elasticacheLabelCacheClusterTransitEncryptionMode] = model.LabelValue(cluster.TransitEncryptionMode) + } + + // Log delivery configurations (slice) + for i, logDelivery := range cluster.LogDeliveryConfigurations { + if len(logDelivery.DestinationType) > 0 { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterLogDeliveryConfigurationDestinationType, i))] = model.LabelValue(logDelivery.DestinationType) + } + if len(logDelivery.LogFormat) > 0 { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterLogDeliveryConfigurationLogFormat, i))] = model.LabelValue(logDelivery.LogFormat) + } + if len(logDelivery.LogType) > 0 { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterLogDeliveryConfigurationLogType, i))] = model.LabelValue(logDelivery.LogType) + } + if len(logDelivery.Status) > 0 { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterLogDeliveryConfigurationStatus, i))] = model.LabelValue(logDelivery.Status) + } + if logDelivery.Message != nil { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterLogDeliveryConfigurationMessage, i))] = model.LabelValue(*logDelivery.Message) + } + if logDelivery.DestinationDetails != nil { + if logDelivery.DestinationDetails.CloudWatchLogsDetails != nil && logDelivery.DestinationDetails.CloudWatchLogsDetails.LogGroup != nil { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterLogDeliveryConfigurationLogGroup, i))] = model.LabelValue(*logDelivery.DestinationDetails.CloudWatchLogsDetails.LogGroup) + } + if logDelivery.DestinationDetails.KinesisFirehoseDetails != nil && logDelivery.DestinationDetails.KinesisFirehoseDetails.DeliveryStream != nil { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterLogDeliveryConfigurationDeliveryStream, i))] = model.LabelValue(*logDelivery.DestinationDetails.KinesisFirehoseDetails.DeliveryStream) + } + } + } + + // Pending modified values + if cluster.PendingModifiedValues != nil { + if len(cluster.PendingModifiedValues.AuthTokenStatus) > 0 { + commonLabels[elasticacheLabelCacheClusterPendingModifiedValuesAuthTokenStatus] = model.LabelValue(cluster.PendingModifiedValues.AuthTokenStatus) + } + if cluster.PendingModifiedValues.CacheNodeType != nil { + commonLabels[elasticacheLabelCacheClusterPendingModifiedValuesCacheNodeType] = model.LabelValue(*cluster.PendingModifiedValues.CacheNodeType) + } + if cluster.PendingModifiedValues.EngineVersion != nil { + commonLabels[elasticacheLabelCacheClusterPendingModifiedValuesEngineVersion] = model.LabelValue(*cluster.PendingModifiedValues.EngineVersion) + } + if cluster.PendingModifiedValues.NumCacheNodes != nil { + commonLabels[elasticacheLabelCacheClusterPendingModifiedValuesNumCacheNodes] = model.LabelValue(strconv.Itoa(int(*cluster.PendingModifiedValues.NumCacheNodes))) + } + if cluster.PendingModifiedValues.TransitEncryptionEnabled != nil { + commonLabels[elasticacheLabelCacheClusterPendingModifiedValuesTransitEncryptionEnabled] = model.LabelValue(strconv.FormatBool(*cluster.PendingModifiedValues.TransitEncryptionEnabled)) + } + if len(cluster.PendingModifiedValues.TransitEncryptionMode) > 0 { + commonLabels[elasticacheLabelCacheClusterPendingModifiedValuesTransitEncryptionMode] = model.LabelValue(cluster.PendingModifiedValues.TransitEncryptionMode) + } + if len(cluster.PendingModifiedValues.CacheNodeIdsToRemove) > 0 { + commonLabels[elasticacheLabelCacheClusterPendingModifiedValuesCacheNodeIDsToRemove] = model.LabelValue(strings.Join(cluster.PendingModifiedValues.CacheNodeIdsToRemove, ",")) + } + } + + // Security group membership (slice) + for i, sg := range cluster.SecurityGroups { + if sg.SecurityGroupId != nil { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterSecurityGroupMembershipID, i))] = model.LabelValue(*sg.SecurityGroupId) + } + if sg.Status != nil { + commonLabels[model.LabelName(fmt.Sprintf("%s_%d", elasticacheLabelCacheClusterSecurityGroupMembershipStatus, i))] = model.LabelValue(*sg.Status) + } + } + + // Tags + for _, tag := range tags { + if tag.Key != nil && tag.Value != nil { + commonLabels[model.LabelName(elasticacheLabelCacheClusterTag+strutil.SanitizeLabelName(*tag.Key))] = model.LabelValue(*tag.Value) + } + } + + // Create one target per cache node + for _, node := range cluster.CacheNodes { + // Clone common labels for this node + labels := make(model.LabelSet, len(commonLabels)) + maps.Copy(labels, commonLabels) + + // Add node-specific labels + if node.CacheNodeId != nil { + labels[elasticacheLabelCacheClusterNodeID] = model.LabelValue(*node.CacheNodeId) + } + if node.CacheNodeStatus != nil { + labels[elasticacheLabelCacheClusterNodeStatus] = model.LabelValue(*node.CacheNodeStatus) + } + if node.CacheNodeCreateTime != nil { + labels[elasticacheLabelCacheClusterNodeCreateTime] = model.LabelValue(node.CacheNodeCreateTime.Format(time.RFC3339)) + } + if node.CustomerAvailabilityZone != nil { + labels[elasticacheLabelCacheClusterNodeAZ] = model.LabelValue(*node.CustomerAvailabilityZone) + } + if node.CustomerOutpostArn != nil { + labels[elasticacheLabelCacheClusterNodeCustomerOutpostARN] = model.LabelValue(*node.CustomerOutpostArn) + } + if node.SourceCacheNodeId != nil { + labels[elasticacheLabelCacheClusterNodeSourceCacheNodeID] = model.LabelValue(*node.SourceCacheNodeId) + } + if node.ParameterGroupStatus != nil { + labels[elasticacheLabelCacheClusterNodeParameterGroupStatus] = model.LabelValue(*node.ParameterGroupStatus) + } + if node.Endpoint != nil { + if node.Endpoint.Address != nil { + labels[elasticacheLabelCacheClusterNodeEndpointAddress] = model.LabelValue(*node.Endpoint.Address) + } + if node.Endpoint.Port != nil { + labels[elasticacheLabelCacheClusterNodeEndpointPort] = model.LabelValue(strconv.Itoa(int(*node.Endpoint.Port))) + } + + // Set the address label to this node's endpoint + if node.Endpoint.Address != nil && node.Endpoint.Port != nil { + labels[model.AddressLabel] = model.LabelValue(net.JoinHostPort(*node.Endpoint.Address, strconv.Itoa(int(*node.Endpoint.Port)))) + } + } + + tg.Targets = append(tg.Targets, labels) + } +} diff --git a/discovery/aws/elasticache_test.go b/discovery/aws/elasticache_test.go new file mode 100644 index 0000000000..4611f33059 --- /dev/null +++ b/discovery/aws/elasticache_test.go @@ -0,0 +1,615 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/elasticache" + "github.com/aws/aws-sdk-go-v2/service/elasticache/types" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/prometheus/prometheus/discovery/targetgroup" +) + +// Struct for test data. +type elasticacheDataStore struct { + region string + serverlessCaches []types.ServerlessCache + cacheClusters []types.CacheCluster + tags map[string][]types.Tag // keyed by cache ARN +} + +func TestElasticacheDiscoveryDescribeServerlessCaches(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + ecData *elasticacheDataStore + cacheNames []string + expectedCount int + }{ + { + name: "MultipleCaches", + ecData: &elasticacheDataStore{ + region: "us-west-2", + serverlessCaches: []types.ServerlessCache{ + { + ServerlessCacheName: strptr("test-cache"), + ARN: strptr("arn:aws:elasticache:us-west-2:123456789012:serverlesscache:test-cache"), + Status: strptr("available"), + Engine: strptr("redis"), + FullEngineVersion: strptr("7.1"), + CreateTime: aws.Time(time.Now()), + Endpoint: &types.Endpoint{ + Address: strptr("test-cache.serverless.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + { + ServerlessCacheName: strptr("prod-cache"), + ARN: strptr("arn:aws:elasticache:us-west-2:123456789012:serverlesscache:prod-cache"), + Status: strptr("available"), + Engine: strptr("valkey"), + FullEngineVersion: strptr("7.2"), + CreateTime: aws.Time(time.Now()), + Endpoint: &types.Endpoint{ + Address: strptr("prod-cache.serverless.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + }, + }, + cacheNames: []string{}, + expectedCount: 2, + }, + { + name: "SingleCache", + ecData: &elasticacheDataStore{ + region: "us-east-1", + serverlessCaches: []types.ServerlessCache{ + { + ServerlessCacheName: strptr("single-cache"), + ARN: strptr("arn:aws:elasticache:us-east-1:123456789012:serverlesscache:single-cache"), + Status: strptr("available"), + Engine: strptr("redis"), + FullEngineVersion: strptr("7.1"), + CreateTime: aws.Time(time.Now()), + }, + }, + }, + cacheNames: []string{"single-cache"}, + expectedCount: 1, + }, + { + name: "NoCaches", + ecData: &elasticacheDataStore{ + region: "us-east-1", + serverlessCaches: []types.ServerlessCache{}, + }, + cacheNames: []string{}, + expectedCount: 0, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockElasticacheClient(tt.ecData) + + d := &ElasticacheDiscovery{ + elasticacheClient: client, + cfg: &ElasticacheSDConfig{ + Region: tt.ecData.region, + RequestConcurrency: 10, + }, + } + + caches, err := d.describeServerlessCaches(ctx, tt.cacheNames) + require.NoError(t, err) + require.Len(t, caches, tt.expectedCount) + }) + } +} + +func TestElasticacheDiscoveryDescribeCacheClusters(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + ecData *elasticacheDataStore + clusterIDs []string + expectedCount int + skipTest bool + }{ + { + name: "MockValidation", + ecData: &elasticacheDataStore{ + region: "us-west-2", + cacheClusters: []types.CacheCluster{ + { + CacheClusterId: strptr("test-cluster-001"), + ARN: strptr("arn:aws:elasticache:us-west-2:123456789012:cluster:test-cluster-001"), + CacheClusterStatus: strptr("available"), + Engine: strptr("redis"), + EngineVersion: strptr("7.1"), + CacheNodeType: strptr("cache.t3.micro"), + NumCacheNodes: aws.Int32(1), + ConfigurationEndpoint: &types.Endpoint{ + Address: strptr("test-cluster.abc123.cfg.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + }, + }, + clusterIDs: []string{}, + expectedCount: 1, + skipTest: false, + }, + { + name: "NoClusters", + ecData: &elasticacheDataStore{ + region: "us-east-1", + cacheClusters: []types.CacheCluster{}, + }, + clusterIDs: []string{}, + expectedCount: 0, + skipTest: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + if tt.skipTest { + t.Skip("Skipping complex test with concurrency") + } + client := newMockElasticacheClient(tt.ecData) + + // Verify mock returns expected data + output, err := client.DescribeCacheClusters(ctx, &elasticache.DescribeCacheClustersInput{}) + require.NoError(t, err) + require.Len(t, output.CacheClusters, tt.expectedCount) + }) + } +} + +func TestAddServerlessCacheTargets(t *testing.T) { + testTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + cache *types.ServerlessCache + tags []types.Tag + expectedLabels model.LabelSet + }{ + { + name: "ServerlessCacheWithEndpoint", + cache: &types.ServerlessCache{ + ServerlessCacheName: strptr("my-cache"), + ARN: strptr("arn:aws:elasticache:us-east-1:123456789012:serverlesscache:my-cache"), + Status: strptr("available"), + Engine: strptr("redis"), + FullEngineVersion: strptr("7.1"), + MajorEngineVersion: strptr("7"), + CreateTime: aws.Time(testTime), + Endpoint: &types.Endpoint{ + Address: strptr("my-cache.serverless.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + ReaderEndpoint: &types.Endpoint{ + Address: strptr("my-cache-ro.serverless.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + SecurityGroupIds: []string{"sg-12345"}, + SubnetIds: []string{"subnet-abcdef"}, + CacheUsageLimits: &types.CacheUsageLimits{ + DataStorage: &types.DataStorage{ + Maximum: aws.Int32(10), + Minimum: aws.Int32(1), + Unit: types.DataStorageUnitGb, + }, + ECPUPerSecond: &types.ECPUPerSecond{ + Maximum: aws.Int32(5000), + Minimum: aws.Int32(1000), + }, + }, + }, + tags: []types.Tag{ + {Key: strptr("Environment"), Value: strptr("test")}, + }, + expectedLabels: model.LabelSet{ + "__meta_elasticache_deployment_option": "serverless", + "__meta_elasticache_serverless_cache_arn": "arn:aws:elasticache:us-east-1:123456789012:serverlesscache:my-cache", + "__meta_elasticache_serverless_cache_name": "my-cache", + "__meta_elasticache_serverless_cache_status": "available", + "__meta_elasticache_serverless_cache_engine": "redis", + "__meta_elasticache_serverless_cache_full_engine_version": "7.1", + "__meta_elasticache_serverless_cache_major_engine_version": "7", + "__meta_elasticache_serverless_cache_create_time": "2024-01-01T00:00:00Z", + "__meta_elasticache_serverless_cache_endpoint_address": "my-cache.serverless.use1.cache.amazonaws.com", + "__meta_elasticache_serverless_cache_endpoint_port": "6379", + + "__meta_elasticache_serverless_cache_security_group_id_0": "sg-12345", + "__meta_elasticache_serverless_cache_subnet_id_0": "subnet-abcdef", + + "__address__": "my-cache.serverless.use1.cache.amazonaws.com:6379", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tg := &targetgroup.Group{ + Source: "test", + } + + addServerlessCacheTargets(tg, tt.cache, tt.tags) + + require.Len(t, tg.Targets, 1) + labels := tg.Targets[0] + + // Check that all expected labels are present with correct values + for k, v := range tt.expectedLabels { + actualValue, exists := labels[k] + require.True(t, exists, "label %s should exist", k) + require.Equal(t, v, actualValue, "label %s mismatch", k) + } + }) + } +} + +func TestAddCacheClusterTargets(t *testing.T) { + testTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + cluster *types.CacheCluster + tags []types.Tag + expectedTargetCount int + expectedLabels []model.LabelSet // One per node + }{ + { + name: "CacheClusterWithMultipleNodes", + cluster: &types.CacheCluster{ + CacheClusterId: strptr("my-cluster-001"), + ARN: strptr("arn:aws:elasticache:us-east-1:123456789012:cluster:my-cluster-001"), + CacheClusterStatus: strptr("available"), + Engine: strptr("redis"), + EngineVersion: strptr("7.1"), + CacheNodeType: strptr("cache.t3.micro"), + NumCacheNodes: aws.Int32(2), + CacheClusterCreateTime: aws.Time(testTime), + ConfigurationEndpoint: &types.Endpoint{ + Address: strptr("my-cluster.abc123.cfg.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + AtRestEncryptionEnabled: aws.Bool(true), + TransitEncryptionEnabled: aws.Bool(true), + AuthTokenEnabled: aws.Bool(true), + AutoMinorVersionUpgrade: aws.Bool(true), + CacheSubnetGroupName: strptr("my-subnet-group"), + PreferredAvailabilityZone: strptr("us-east-1a"), + SecurityGroups: []types.SecurityGroupMembership{ + { + SecurityGroupId: strptr("sg-12345"), + Status: strptr("active"), + }, + }, + CacheNodes: []types.CacheNode{ + { + CacheNodeId: strptr("0001"), + CacheNodeStatus: strptr("available"), + CacheNodeCreateTime: aws.Time(testTime), + CustomerAvailabilityZone: strptr("us-east-1a"), + Endpoint: &types.Endpoint{ + Address: strptr("my-cluster-001.abc123.0001.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + { + CacheNodeId: strptr("0002"), + CacheNodeStatus: strptr("available"), + CacheNodeCreateTime: aws.Time(testTime), + CustomerAvailabilityZone: strptr("us-east-1b"), + Endpoint: &types.Endpoint{ + Address: strptr("my-cluster-001.abc123.0002.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + }, + }, + tags: []types.Tag{ + {Key: strptr("Environment"), Value: strptr("production")}, + {Key: strptr("Application"), Value: strptr("web-app")}, + }, + expectedTargetCount: 2, + expectedLabels: []model.LabelSet{ + { + "__meta_elasticache_deployment_option": "node", + "__meta_elasticache_cache_cluster_arn": "arn:aws:elasticache:us-east-1:123456789012:cluster:my-cluster-001", + "__meta_elasticache_cache_cluster_cache_cluster_id": "my-cluster-001", + "__meta_elasticache_cache_cluster_cache_cluster_status": "available", + "__meta_elasticache_cache_cluster_engine": "redis", + "__meta_elasticache_cache_cluster_engine_version": "7.1", + "__meta_elasticache_cache_cluster_cache_node_type": "cache.t3.micro", + "__meta_elasticache_cache_cluster_num_cache_nodes": "2", + "__meta_elasticache_cache_cluster_cache_cluster_create_time": "2024-01-01T00:00:00Z", + "__meta_elasticache_cache_cluster_configuration_endpoint_address": "my-cluster.abc123.cfg.use1.cache.amazonaws.com", + "__meta_elasticache_cache_cluster_configuration_endpoint_port": "6379", + "__meta_elasticache_cache_cluster_at_rest_encryption_enabled": "true", + "__meta_elasticache_cache_cluster_transit_encryption_enabled": "true", + "__meta_elasticache_cache_cluster_auth_token_enabled": "true", + "__meta_elasticache_cache_cluster_auto_minor_version_upgrade": "true", + "__meta_elasticache_cache_cluster_cache_subnet_group_name": "my-subnet-group", + "__meta_elasticache_cache_cluster_preferred_availability_zone": "us-east-1a", + "__meta_elasticache_cache_cluster_security_group_membership_id_0": "sg-12345", + "__meta_elasticache_cache_cluster_security_group_membership_status_0": "active", + "__meta_elasticache_cache_cluster_tag_Environment": "production", + "__meta_elasticache_cache_cluster_tag_Application": "web-app", + "__meta_elasticache_cache_cluster_node_id": "0001", + "__meta_elasticache_cache_cluster_node_status": "available", + "__meta_elasticache_cache_cluster_node_create_time": "2024-01-01T00:00:00Z", + "__meta_elasticache_cache_cluster_node_availability_zone": "us-east-1a", + "__meta_elasticache_cache_cluster_node_endpoint_address": "my-cluster-001.abc123.0001.use1.cache.amazonaws.com", + "__meta_elasticache_cache_cluster_node_endpoint_port": "6379", + "__address__": "my-cluster-001.abc123.0001.use1.cache.amazonaws.com:6379", + }, + { + "__meta_elasticache_deployment_option": "node", + "__meta_elasticache_cache_cluster_arn": "arn:aws:elasticache:us-east-1:123456789012:cluster:my-cluster-001", + "__meta_elasticache_cache_cluster_cache_cluster_id": "my-cluster-001", + "__meta_elasticache_cache_cluster_cache_cluster_status": "available", + "__meta_elasticache_cache_cluster_engine": "redis", + "__meta_elasticache_cache_cluster_engine_version": "7.1", + "__meta_elasticache_cache_cluster_cache_node_type": "cache.t3.micro", + "__meta_elasticache_cache_cluster_num_cache_nodes": "2", + "__meta_elasticache_cache_cluster_cache_cluster_create_time": "2024-01-01T00:00:00Z", + "__meta_elasticache_cache_cluster_configuration_endpoint_address": "my-cluster.abc123.cfg.use1.cache.amazonaws.com", + "__meta_elasticache_cache_cluster_configuration_endpoint_port": "6379", + "__meta_elasticache_cache_cluster_at_rest_encryption_enabled": "true", + "__meta_elasticache_cache_cluster_transit_encryption_enabled": "true", + "__meta_elasticache_cache_cluster_auth_token_enabled": "true", + "__meta_elasticache_cache_cluster_auto_minor_version_upgrade": "true", + "__meta_elasticache_cache_cluster_cache_subnet_group_name": "my-subnet-group", + "__meta_elasticache_cache_cluster_preferred_availability_zone": "us-east-1a", + "__meta_elasticache_cache_cluster_security_group_membership_id_0": "sg-12345", + "__meta_elasticache_cache_cluster_security_group_membership_status_0": "active", + "__meta_elasticache_cache_cluster_tag_Environment": "production", + "__meta_elasticache_cache_cluster_tag_Application": "web-app", + "__meta_elasticache_cache_cluster_node_id": "0002", + "__meta_elasticache_cache_cluster_node_status": "available", + "__meta_elasticache_cache_cluster_node_create_time": "2024-01-01T00:00:00Z", + "__meta_elasticache_cache_cluster_node_availability_zone": "us-east-1b", + "__meta_elasticache_cache_cluster_node_endpoint_address": "my-cluster-001.abc123.0002.use1.cache.amazonaws.com", + "__meta_elasticache_cache_cluster_node_endpoint_port": "6379", + "__address__": "my-cluster-001.abc123.0002.use1.cache.amazonaws.com:6379", + }, + }, + }, + { + name: "CacheClusterWithSingleNode", + cluster: &types.CacheCluster{ + CacheClusterId: strptr("node-cluster-001"), + ARN: strptr("arn:aws:elasticache:us-east-1:123456789012:cluster:node-cluster-001"), + CacheClusterStatus: strptr("available"), + Engine: strptr("redis"), + EngineVersion: strptr("6.2"), + CacheNodeType: strptr("cache.r6g.large"), + NumCacheNodes: aws.Int32(1), + CacheNodes: []types.CacheNode{ + { + CacheNodeId: strptr("0001"), + CacheNodeStatus: strptr("available"), + CacheNodeCreateTime: aws.Time(testTime), + CustomerAvailabilityZone: strptr("us-east-1a"), + Endpoint: &types.Endpoint{ + Address: strptr("node-cluster-001.abc123.0001.use1.cache.amazonaws.com"), + Port: aws.Int32(6379), + }, + }, + }, + }, + tags: []types.Tag{}, + expectedTargetCount: 1, + expectedLabels: []model.LabelSet{ + { + "__meta_elasticache_deployment_option": "node", + "__meta_elasticache_cache_cluster_arn": "arn:aws:elasticache:us-east-1:123456789012:cluster:node-cluster-001", + "__meta_elasticache_cache_cluster_cache_cluster_id": "node-cluster-001", + "__meta_elasticache_cache_cluster_cache_cluster_status": "available", + "__meta_elasticache_cache_cluster_engine": "redis", + "__meta_elasticache_cache_cluster_engine_version": "6.2", + "__meta_elasticache_cache_cluster_cache_node_type": "cache.r6g.large", + "__meta_elasticache_cache_cluster_num_cache_nodes": "1", + "__meta_elasticache_cache_cluster_node_id": "0001", + "__meta_elasticache_cache_cluster_node_status": "available", + "__meta_elasticache_cache_cluster_node_create_time": "2024-01-01T00:00:00Z", + "__meta_elasticache_cache_cluster_node_availability_zone": "us-east-1a", + "__meta_elasticache_cache_cluster_node_endpoint_address": "node-cluster-001.abc123.0001.use1.cache.amazonaws.com", + "__meta_elasticache_cache_cluster_node_endpoint_port": "6379", + "__address__": "node-cluster-001.abc123.0001.use1.cache.amazonaws.com:6379", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tg := &targetgroup.Group{ + Source: "test", + } + + addCacheClusterTargets(tg, tt.cluster, tt.tags) + + require.Len(t, tg.Targets, tt.expectedTargetCount) + + // Check each target + for i, expectedLabels := range tt.expectedLabels { + labels := tg.Targets[i] + + // Check that all expected labels are present with correct values + for k, v := range expectedLabels { + actualValue, exists := labels[k] + require.True(t, exists, "label %s should exist in target %d", k, i) + require.Equal(t, v, actualValue, "label %s mismatch in target %d", k, i) + } + } + }) + } +} + +// Mock Elasticache client. +type mockElasticacheClient struct { + data *elasticacheDataStore +} + +func newMockElasticacheClient(data *elasticacheDataStore) *mockElasticacheClient { + return &mockElasticacheClient{data: data} +} + +func (m *mockElasticacheClient) DescribeServerlessCaches(_ context.Context, input *elasticache.DescribeServerlessCachesInput, _ ...func(*elasticache.Options)) (*elasticache.DescribeServerlessCachesOutput, error) { + if input.ServerlessCacheName != nil { + // Filter by name + for _, cache := range m.data.serverlessCaches { + if cache.ServerlessCacheName != nil && *cache.ServerlessCacheName == *input.ServerlessCacheName { + return &elasticache.DescribeServerlessCachesOutput{ + ServerlessCaches: []types.ServerlessCache{cache}, + }, nil + } + } + return &elasticache.DescribeServerlessCachesOutput{ + ServerlessCaches: []types.ServerlessCache{}, + }, nil + } + + return &elasticache.DescribeServerlessCachesOutput{ + ServerlessCaches: m.data.serverlessCaches, + }, nil +} + +func (m *mockElasticacheClient) DescribeCacheClusters(_ context.Context, input *elasticache.DescribeCacheClustersInput, _ ...func(*elasticache.Options)) (*elasticache.DescribeCacheClustersOutput, error) { + if input.CacheClusterId != nil { + // Single cluster lookup + for _, cluster := range m.data.cacheClusters { + if cluster.CacheClusterId != nil && *cluster.CacheClusterId == *input.CacheClusterId { + return &elasticache.DescribeCacheClustersOutput{ + CacheClusters: []types.CacheCluster{cluster}, + }, nil + } + } + return &elasticache.DescribeCacheClustersOutput{ + CacheClusters: []types.CacheCluster{}, + }, nil + } + + return &elasticache.DescribeCacheClustersOutput{ + CacheClusters: m.data.cacheClusters, + }, nil +} + +func (m *mockElasticacheClient) ListTagsForResource(_ context.Context, input *elasticache.ListTagsForResourceInput, _ ...func(*elasticache.Options)) (*elasticache.ListTagsForResourceOutput, error) { + if input.ResourceName != nil { + if tags, ok := m.data.tags[*input.ResourceName]; ok { + return &elasticache.ListTagsForResourceOutput{ + TagList: tags, + }, nil + } + } + + return &elasticache.ListTagsForResourceOutput{ + TagList: []types.Tag{}, + }, nil +} + +func TestSplitCacheDeploymentOptions(t *testing.T) { + tests := []struct { + name string + caches []string + expectedServerlessCacheIDs []string + expectedCacheClusterIDs []string + }{ + { + name: "MixedARNs", + caches: []string{ + "arn:aws:elasticache:us-east-1:123456789012:serverlesscache:my-serverless-cache", + "arn:aws:elasticache:us-east-1:123456789012:replicationgroup:my-replication-group", + "arn:aws:elasticache:us-west-2:123456789012:serverlesscache:prod-cache", + }, + expectedServerlessCacheIDs: []string{"my-serverless-cache", "prod-cache"}, + expectedCacheClusterIDs: []string{"my-replication-group"}, + }, + { + name: "OnlyServerlessCaches", + caches: []string{ + "arn:aws:elasticache:us-east-1:123456789012:serverlesscache:cache-1", + "arn:aws:elasticache:us-east-1:123456789012:serverlesscache:cache-2", + }, + expectedServerlessCacheIDs: []string{"cache-1", "cache-2"}, + expectedCacheClusterIDs: nil, + }, + { + name: "OnlyReplicationGroups", + caches: []string{ + "arn:aws:elasticache:us-east-1:123456789012:replicationgroup:cluster-1", + "arn:aws:elasticache:us-east-1:123456789012:replicationgroup:cluster-2", + }, + expectedServerlessCacheIDs: nil, + expectedCacheClusterIDs: []string{"cluster-1", "cluster-2"}, + }, + { + name: "EmptyInput", + caches: []string{}, + expectedServerlessCacheIDs: nil, + expectedCacheClusterIDs: nil, + }, + { + name: "InvalidARNs", + caches: []string{ + "not-an-arn", + "arn:aws:elasticache:us-east-1", + "", + }, + expectedServerlessCacheIDs: nil, + expectedCacheClusterIDs: nil, + }, + { + name: "UnknownResourceType", + caches: []string{ + "arn:aws:elasticache:us-east-1:123456789012:unknown:resource-id", + }, + expectedServerlessCacheIDs: nil, + expectedCacheClusterIDs: nil, + }, + { + name: "MixedWithInvalidARNs", + caches: []string{ + "arn:aws:elasticache:us-east-1:123456789012:serverlesscache:valid-cache", + "invalid-arn", + "arn:aws:elasticache:us-east-1:123456789012:replicationgroup:valid-cluster", + "", + "arn:aws:elasticache:us-east-1:123456789012:unknown:ignored", + }, + expectedServerlessCacheIDs: []string{"valid-cache"}, + expectedCacheClusterIDs: []string{"valid-cluster"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serverlessCacheIDs, cacheClusterIDs := splitCacheDeploymentOptions(tt.caches) + + require.Equal(t, tt.expectedServerlessCacheIDs, serverlessCacheIDs, "serverless cache IDs mismatch") + require.Equal(t, tt.expectedCacheClusterIDs, cacheClusterIDs, "cache cluster IDs mismatch") + }) + } +} diff --git a/discovery/aws/lightsail.go b/discovery/aws/lightsail.go index 5441e510b9..39e4716957 100644 --- a/discovery/aws/lightsail.go +++ b/discovery/aws/lightsail.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net" "strconv" "strings" @@ -27,7 +26,6 @@ import ( awsConfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" - "github.com/aws/aws-sdk-go-v2/feature/ec2/imds" "github.com/aws/aws-sdk-go-v2/service/lightsail" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/smithy-go" @@ -95,7 +93,7 @@ func (*LightsailSDConfig) Name() string { return "lightsail" } // NewDiscoverer returns a Discoverer for the Lightsail Config. func (c *LightsailSDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewLightsailDiscovery(c, opts.Logger, opts.Metrics) + return NewLightsailDiscovery(c, opts) } // UnmarshalYAML implements the yaml.Unmarshaler interface for the Lightsail Config. @@ -107,30 +105,9 @@ func (c *LightsailSDConfig) UnmarshalYAML(unmarshal func(any) error) error { return err } - if c.Region == "" { - cfg, err := awsConfig.LoadDefaultConfig(context.Background()) - if err != nil { - return err - } - - if cfg.Region != "" { - // Use the region from the AWS config. It will load environment variables and shared config files. - c.Region = cfg.Region - } - - if c.Region == "" { - // Try to get the region from the instance metadata service (IMDS). - imdsClient := imds.NewFromConfig(cfg) - region, err := imdsClient.GetRegion(context.Background(), &imds.GetRegionInput{}) - if err != nil { - return err - } - c.Region = region.Region - } - } - - if c.Region == "" { - return errors.New("lightsail SD configuration requires a region") + c.Region, err = loadRegion(context.Background(), c.Region) + if err != nil { + return fmt.Errorf("could not determine AWS region: %w", err) } return c.HTTPClientConfig.Validate() @@ -145,14 +122,14 @@ type LightsailDiscovery struct { } // NewLightsailDiscovery returns a new LightsailDiscovery which periodically refreshes its targets. -func NewLightsailDiscovery(conf *LightsailSDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*LightsailDiscovery, error) { - m, ok := metrics.(*lightsailMetrics) +func NewLightsailDiscovery(conf *LightsailSDConfig, opts discovery.DiscovererOptions) (*LightsailDiscovery, error) { + m, ok := opts.Metrics.(*lightsailMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - if logger == nil { - logger = promslog.NewNopLogger() + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() } d := &LightsailDiscovery{ @@ -160,8 +137,9 @@ func NewLightsailDiscovery(conf *LightsailSDConfig, logger *slog.Logger, metrics } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "lightsail", + SetName: opts.SetName, Interval: time.Duration(d.cfg.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, @@ -210,7 +188,12 @@ func (d *LightsailDiscovery) lightsailClient(ctx context.Context) (*lightsail.Cl cfg.Credentials = aws.NewCredentialsCache(assumeProvider) } - d.lightsail = lightsail.NewFromConfig(cfg) + d.lightsail = lightsail.NewFromConfig(cfg, func(options *lightsail.Options) { + if d.cfg.Endpoint != "" { + options.BaseEndpoint = &d.cfg.Endpoint + } + options.HTTPClient = httpClient + }) return d.lightsail, nil } diff --git a/discovery/aws/metrics_aws.go b/discovery/aws/metrics_aws.go new file mode 100644 index 0000000000..4cb9b25041 --- /dev/null +++ b/discovery/aws/metrics_aws.go @@ -0,0 +1,32 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "github.com/prometheus/prometheus/discovery" +) + +type awsMetrics struct { + refreshMetrics discovery.RefreshMetricsInstantiator +} + +var _ discovery.DiscovererMetrics = (*awsMetrics)(nil) + +// Register implements discovery.DiscovererMetrics. +func (*awsMetrics) Register() error { + return nil +} + +// Unregister implements discovery.DiscovererMetrics. +func (*awsMetrics) Unregister() {} diff --git a/discovery/aws/metrics_ec2.go b/discovery/aws/metrics_ec2.go index 45227c3534..1a37347b40 100644 --- a/discovery/aws/metrics_ec2.go +++ b/discovery/aws/metrics_ec2.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/aws/metrics_ecs.go b/discovery/aws/metrics_ecs.go new file mode 100644 index 0000000000..dde3483c06 --- /dev/null +++ b/discovery/aws/metrics_ecs.go @@ -0,0 +1,32 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "github.com/prometheus/prometheus/discovery" +) + +type ecsMetrics struct { + refreshMetrics discovery.RefreshMetricsInstantiator +} + +var _ discovery.DiscovererMetrics = (*ecsMetrics)(nil) + +// Register implements discovery.DiscovererMetrics. +func (*ecsMetrics) Register() error { + return nil +} + +// Unregister implements discovery.DiscovererMetrics. +func (*ecsMetrics) Unregister() {} diff --git a/discovery/aws/metrics_elasticache.go b/discovery/aws/metrics_elasticache.go new file mode 100644 index 0000000000..7ecfcb4b72 --- /dev/null +++ b/discovery/aws/metrics_elasticache.go @@ -0,0 +1,32 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "github.com/prometheus/prometheus/discovery" +) + +type elasticacheMetrics struct { + refreshMetrics discovery.RefreshMetricsInstantiator +} + +var _ discovery.DiscovererMetrics = (*elasticacheMetrics)(nil) + +// Register implements discovery.DiscovererMetrics. +func (*elasticacheMetrics) Register() error { + return nil +} + +// Unregister implements discovery.DiscovererMetrics. +func (*elasticacheMetrics) Unregister() {} diff --git a/discovery/aws/metrics_lightsail.go b/discovery/aws/metrics_lightsail.go index 4dfe14c60c..40f7639459 100644 --- a/discovery/aws/metrics_lightsail.go +++ b/discovery/aws/metrics_lightsail.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/aws/metrics_msk.go b/discovery/aws/metrics_msk.go new file mode 100644 index 0000000000..fc69f57aa1 --- /dev/null +++ b/discovery/aws/metrics_msk.go @@ -0,0 +1,32 @@ +// Copyright 2015 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "github.com/prometheus/prometheus/discovery" +) + +type mskMetrics struct { + refreshMetrics discovery.RefreshMetricsInstantiator +} + +var _ discovery.DiscovererMetrics = (*mskMetrics)(nil) + +// Register implements discovery.DiscovererMetrics. +func (*mskMetrics) Register() error { + return nil +} + +// Unregister implements discovery.DiscovererMetrics. +func (*mskMetrics) Unregister() {} diff --git a/discovery/aws/msk.go b/discovery/aws/msk.go new file mode 100644 index 0000000000..3ecc1e6235 --- /dev/null +++ b/discovery/aws/msk.go @@ -0,0 +1,451 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "strconv" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/kafka" + "github.com/aws/aws-sdk-go-v2/service/kafka/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/prometheus/common/promslog" + "golang.org/x/sync/errgroup" + + "github.com/prometheus/prometheus/discovery" + "github.com/prometheus/prometheus/discovery/refresh" + "github.com/prometheus/prometheus/discovery/targetgroup" + "github.com/prometheus/prometheus/util/strutil" +) + +type NodeType string + +const ( + NodeTypeBroker NodeType = "BROKER" + NodeTypeController NodeType = "CONTROLLER" +) + +const ( + mskLabel = model.MetaLabelPrefix + "msk_" + + // Cluster labels. + mskLabelCluster = mskLabel + "cluster_" + mskLabelClusterName = mskLabelCluster + "name" + mskLabelClusterARN = mskLabelCluster + "arn" + mskLabelClusterState = mskLabelCluster + "state" + mskLabelClusterType = mskLabelCluster + "type" + mskLabelClusterVersion = mskLabelCluster + "version" + mskLabelClusterJmxExporterEnabled = mskLabelCluster + "jmx_exporter_enabled" + mskLabelClusterConfigurationARN = mskLabelCluster + "configuration_arn" + mskLabelClusterConfigurationRevision = mskLabelCluster + "configuration_revision" + mskLabelClusterKafkaVersion = mskLabelCluster + "kafka_version" + mskLabelClusterTags = mskLabelCluster + "tag_" + + // Node labels. + mskLabelNode = mskLabel + "node_" + mskLabelNodeType = mskLabelNode + "type" + mskLabelNodeARN = mskLabelNode + "arn" + mskLabelNodeAddedTime = mskLabelNode + "added_time" + mskLabelNodeInstanceType = mskLabelNode + "instance_type" + mskLabelNodeAttachedENI = mskLabelNode + "attached_eni" + + // Broker labels. + mskLabelBroker = mskLabel + "broker_" + mskLabelBrokerEndpointIndex = mskLabelBroker + "endpoint_index" + mskLabelBrokerID = mskLabelBroker + "id" + mskLabelBrokerClientSubnet = mskLabelBroker + "client_subnet" + mskLabelBrokerClientVPCIP = mskLabelBroker + "client_vpc_ip" + mskLabelBrokerNodeExporterEnabled = mskLabelBroker + "node_exporter_enabled" + + // Controller labels. + mskLabelController = mskLabel + "controller_" + mskLabelControllerEndpointIndex = mskLabelController + "endpoint_index" +) + +// DefaultMSKSDConfig is the default MSK SD configuration. +var DefaultMSKSDConfig = MSKSDConfig{ + Port: 80, + RefreshInterval: model.Duration(60 * time.Second), + RequestConcurrency: 10, + HTTPClientConfig: config.DefaultHTTPClientConfig, +} + +func init() { + discovery.RegisterConfig(&MSKSDConfig{}) +} + +// MSKSDConfig is the configuration for MSK based service discovery. +type MSKSDConfig struct { + Region string `yaml:"region"` + Endpoint string `yaml:"endpoint"` + AccessKey string `yaml:"access_key,omitempty"` + SecretKey config.Secret `yaml:"secret_key,omitempty"` + Profile string `yaml:"profile,omitempty"` + RoleARN string `yaml:"role_arn,omitempty"` + Clusters []string `yaml:"clusters,omitempty"` + Port int `yaml:"port"` + RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"` + + RequestConcurrency int `yaml:"request_concurrency,omitempty"` + HTTPClientConfig config.HTTPClientConfig `yaml:",inline"` +} + +// NewDiscovererMetrics implements discovery.Config. +func (*MSKSDConfig) NewDiscovererMetrics(_ prometheus.Registerer, rmi discovery.RefreshMetricsInstantiator) discovery.DiscovererMetrics { + return &mskMetrics{ + refreshMetrics: rmi, + } +} + +// Name returns the name of the MSK Config. +func (*MSKSDConfig) Name() string { return "msk" } + +// NewDiscoverer returns a Discoverer for the MSK Config. +func (c *MSKSDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewMSKDiscovery(c, opts) +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for the MSK Config. +func (c *MSKSDConfig) UnmarshalYAML(unmarshal func(any) error) error { + *c = DefaultMSKSDConfig + type plain MSKSDConfig + err := unmarshal((*plain)(c)) + if err != nil { + return err + } + + c.Region, err = loadRegion(context.Background(), c.Region) + if err != nil { + return fmt.Errorf("could not determine AWS region: %w", err) + } + + return c.HTTPClientConfig.Validate() +} + +type mskClient interface { + DescribeClusterV2(context.Context, *kafka.DescribeClusterV2Input, ...func(*kafka.Options)) (*kafka.DescribeClusterV2Output, error) + ListClustersV2(context.Context, *kafka.ListClustersV2Input, ...func(*kafka.Options)) (*kafka.ListClustersV2Output, error) + ListNodes(context.Context, *kafka.ListNodesInput, ...func(*kafka.Options)) (*kafka.ListNodesOutput, error) +} + +// MSKDiscovery periodically performs MSK-SD requests. It implements +// the Discoverer interface. +type MSKDiscovery struct { + *refresh.Discovery + logger *slog.Logger + cfg *MSKSDConfig + msk mskClient +} + +// NewMSKDiscovery returns a new MSKDiscovery which periodically refreshes its targets. +func NewMSKDiscovery(conf *MSKSDConfig, opts discovery.DiscovererOptions) (*MSKDiscovery, error) { + m, ok := opts.Metrics.(*mskMetrics) + if !ok { + return nil, errors.New("invalid discovery metrics type") + } + + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() + } + d := &MSKDiscovery{ + logger: opts.Logger, + cfg: conf, + } + d.Discovery = refresh.NewDiscovery( + refresh.Options{ + Logger: opts.Logger, + Mech: "msk", + Interval: time.Duration(d.cfg.RefreshInterval), + RefreshF: d.refresh, + MetricsInstantiator: m.refreshMetrics, + }, + ) + return d, nil +} + +func (d *MSKDiscovery) initMskClient(ctx context.Context) error { + if d.msk != nil { + return nil + } + + if d.cfg.Region == "" { + return errors.New("region must be set for MSK service discovery") + } + + // Build the HTTP client from the provided HTTPClientConfig. + client, err := config.NewClientFromConfig(d.cfg.HTTPClientConfig, "msk_sd") + if err != nil { + return err + } + + // Build the AWS config with the provided region. + var configOptions []func(*awsConfig.LoadOptions) error + configOptions = append(configOptions, awsConfig.WithRegion(d.cfg.Region)) + configOptions = append(configOptions, awsConfig.WithHTTPClient(client)) + + // Only set static credentials if both access key and secret key are provided + // Otherwise, let AWS SDK use its default credential chain + if d.cfg.AccessKey != "" && d.cfg.SecretKey != "" { + credProvider := credentials.NewStaticCredentialsProvider(d.cfg.AccessKey, string(d.cfg.SecretKey), "") + configOptions = append(configOptions, awsConfig.WithCredentialsProvider(credProvider)) + } + + if d.cfg.Profile != "" { + configOptions = append(configOptions, awsConfig.WithSharedConfigProfile(d.cfg.Profile)) + } + + cfg, err := awsConfig.LoadDefaultConfig(ctx, configOptions...) + if err != nil { + d.logger.Error("Failed to create AWS config", "error", err) + return fmt.Errorf("could not create aws config: %w", err) + } + + // If the role ARN is set, assume the role to get credentials and set the credentials provider in the config. + if d.cfg.RoleARN != "" { + assumeProvider := stscreds.NewAssumeRoleProvider(sts.NewFromConfig(cfg), d.cfg.RoleARN) + cfg.Credentials = aws.NewCredentialsCache(assumeProvider) + } + + d.msk = kafka.NewFromConfig(cfg, func(options *kafka.Options) { + if d.cfg.Endpoint != "" { + options.BaseEndpoint = &d.cfg.Endpoint + } + options.HTTPClient = client + }) + + // Test credentials by making a simple API call + testCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + _, err = d.msk.ListClustersV2(testCtx, &kafka.ListClustersV2Input{}) + if err != nil { + d.logger.Error("Failed to test MSK credentials", "error", err) + return fmt.Errorf("MSK credential test failed: %w", err) + } + + return nil +} + +// describeClusters describes the clusters with the given ARNs and returns their details. +func (d *MSKDiscovery) describeClusters(ctx context.Context, clusterARNs []string) ([]types.Cluster, error) { + var ( + clusters []types.Cluster + mu sync.Mutex + ) + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for _, clusterARN := range clusterARNs { + errg.Go(func() error { + cluster, err := d.msk.DescribeClusterV2(ectx, &kafka.DescribeClusterV2Input{ + ClusterArn: aws.String(clusterARN), + }) + if err != nil { + return fmt.Errorf("could not describe cluster %v: %w", clusterARN, err) + } + mu.Lock() + clusters = append(clusters, *cluster.ClusterInfo) + mu.Unlock() + return nil + }) + } + + return clusters, errg.Wait() +} + +// listClusters lists all MSK clusters in the configured region and returns their details. +func (d *MSKDiscovery) listClusters(ctx context.Context) ([]types.Cluster, error) { + var ( + clusters []types.Cluster + nextToken *string + ) + for { + listClustersInput := kafka.ListClustersV2Input{ + ClusterTypeFilter: aws.String("PROVISIONED"), + MaxResults: aws.Int32(100), + NextToken: nextToken, + } + + resp, err := d.msk.ListClustersV2(ctx, &listClustersInput) + if err != nil { + return nil, fmt.Errorf("could not list clusters: %w", err) + } + + clusters = append(clusters, resp.ClusterInfoList...) + if resp.NextToken == nil { + break + } + nextToken = resp.NextToken + } + + return clusters, nil +} + +// listNodes lists all nodes for the given clusters and returns a map of cluster ARN to its nodes. +func (d *MSKDiscovery) listNodes(ctx context.Context, clusters []types.Cluster) (map[string][]types.NodeInfo, error) { + clusterNodeMap := make(map[string][]types.NodeInfo) + mu := sync.Mutex{} + errg, ectx := errgroup.WithContext(ctx) + errg.SetLimit(d.cfg.RequestConcurrency) + for _, cluster := range clusters { + clusterARN := aws.ToString(cluster.ClusterArn) + errg.Go(func() error { + var clusterNodes []types.NodeInfo + var nextToken *string + for { + resp, err := d.msk.ListNodes(ectx, &kafka.ListNodesInput{ + ClusterArn: aws.String(clusterARN), + MaxResults: aws.Int32(100), + NextToken: nextToken, + }) + if err != nil { + return fmt.Errorf("could not list nodes for cluster %v: %w", clusterARN, err) + } + + clusterNodes = append(clusterNodes, resp.NodeInfoList...) + if resp.NextToken == nil { + break + } + nextToken = resp.NextToken + } + + mu.Lock() + clusterNodeMap[clusterARN] = clusterNodes + mu.Unlock() + return nil + }) + } + + return clusterNodeMap, errg.Wait() +} + +func (d *MSKDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { + err := d.initMskClient(ctx) + if err != nil { + return nil, err + } + + tg := &targetgroup.Group{ + Source: d.cfg.Region, + } + + var clusters []types.Cluster + if len(d.cfg.Clusters) > 0 { + clusters, err = d.describeClusters(ctx, d.cfg.Clusters) + if err != nil { + return nil, err + } + } else { + clusters, err = d.listClusters(ctx) + if err != nil { + return nil, err + } + } + + clusterNodeMap, err := d.listNodes(ctx, clusters) + if err != nil { + return nil, err + } + + var ( + targetsMu sync.Mutex + wg sync.WaitGroup + ) + for _, cluster := range clusters { + wg.Add(1) + + go func(cluster types.Cluster, nodes []types.NodeInfo) { + defer wg.Done() + for _, node := range nodes { + labels := model.LabelSet{ + mskLabelClusterName: model.LabelValue(aws.ToString(cluster.ClusterName)), + mskLabelClusterARN: model.LabelValue(aws.ToString(cluster.ClusterArn)), + mskLabelClusterState: model.LabelValue(string(cluster.State)), + mskLabelClusterType: model.LabelValue(string(cluster.ClusterType)), + mskLabelClusterVersion: model.LabelValue(aws.ToString(cluster.CurrentVersion)), + mskLabelNodeARN: model.LabelValue(aws.ToString(node.NodeARN)), + mskLabelNodeAddedTime: model.LabelValue(aws.ToString(node.AddedToClusterTime)), + mskLabelNodeInstanceType: model.LabelValue(aws.ToString(node.InstanceType)), + mskLabelClusterJmxExporterEnabled: model.LabelValue(strconv.FormatBool(*cluster.Provisioned.OpenMonitoring.Prometheus.JmxExporter.EnabledInBroker)), + mskLabelClusterConfigurationARN: model.LabelValue(aws.ToString(cluster.Provisioned.CurrentBrokerSoftwareInfo.ConfigurationArn)), + mskLabelClusterConfigurationRevision: model.LabelValue(strconv.FormatInt(*cluster.Provisioned.CurrentBrokerSoftwareInfo.ConfigurationRevision, 10)), + mskLabelClusterKafkaVersion: model.LabelValue(aws.ToString(cluster.Provisioned.CurrentBrokerSoftwareInfo.KafkaVersion)), + } + + for key, value := range cluster.Tags { + labels[model.LabelName(mskLabelClusterTags+strutil.SanitizeLabelName(key))] = model.LabelValue(value) + } + + switch nodeType(node) { + case NodeTypeBroker: + labels[mskLabelNodeType] = model.LabelValue(NodeTypeBroker) + labels[mskLabelNodeAttachedENI] = model.LabelValue(aws.ToString(node.BrokerNodeInfo.AttachedENIId)) + labels[mskLabelBrokerID] = model.LabelValue(fmt.Sprintf("%.0f", aws.ToFloat64(node.BrokerNodeInfo.BrokerId))) + labels[mskLabelBrokerClientSubnet] = model.LabelValue(aws.ToString(node.BrokerNodeInfo.ClientSubnet)) + labels[mskLabelBrokerClientVPCIP] = model.LabelValue(aws.ToString(node.BrokerNodeInfo.ClientVpcIpAddress)) + labels[mskLabelBrokerNodeExporterEnabled] = model.LabelValue(strconv.FormatBool(*cluster.Provisioned.OpenMonitoring.Prometheus.NodeExporter.EnabledInBroker)) + + for idx, endpoint := range node.BrokerNodeInfo.Endpoints { + endpointLabels := labels.Clone() + endpointLabels[mskLabelBrokerEndpointIndex] = model.LabelValue(strconv.Itoa(idx)) + endpointLabels[model.AddressLabel] = model.LabelValue(net.JoinHostPort(endpoint, strconv.Itoa(d.cfg.Port))) + + targetsMu.Lock() + tg.Targets = append(tg.Targets, endpointLabels) + targetsMu.Unlock() + } + + case NodeTypeController: + labels[mskLabelNodeType] = model.LabelValue(NodeTypeController) + + for idx, endpoint := range node.ControllerNodeInfo.Endpoints { + endpointLabels := labels.Clone() + endpointLabels[mskLabelControllerEndpointIndex] = model.LabelValue(strconv.Itoa(idx)) + endpointLabels[model.AddressLabel] = model.LabelValue(net.JoinHostPort(endpoint, strconv.Itoa(d.cfg.Port))) + + targetsMu.Lock() + tg.Targets = append(tg.Targets, endpointLabels) + targetsMu.Unlock() + } + default: + continue + } + } + }(cluster, clusterNodeMap[aws.ToString(cluster.ClusterArn)]) + } + wg.Wait() + + return []*targetgroup.Group{tg}, nil +} + +func nodeType(node types.NodeInfo) NodeType { + if node.BrokerNodeInfo != nil { + return NodeTypeBroker + } else if node.ControllerNodeInfo != nil { + return NodeTypeController + } + return "" +} diff --git a/discovery/aws/msk_test.go b/discovery/aws/msk_test.go new file mode 100644 index 0000000000..b1d48a7ea6 --- /dev/null +++ b/discovery/aws/msk_test.go @@ -0,0 +1,1131 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package aws + +import ( + "context" + "fmt" + "sort" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/kafka" + "github.com/aws/aws-sdk-go-v2/service/kafka/types" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/prometheus/prometheus/discovery/targetgroup" +) + +// Struct for test data. +type mskDataStore struct { + region string + clusters []types.Cluster + nodes map[string][]types.NodeInfo // keyed by cluster ARN +} + +func TestMSKDiscoveryListClusters(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + mskData *mskDataStore + expected []types.Cluster + }{ + { + name: "MultipleClusters", + mskData: &mskDataStore{ + region: "us-west-2", + clusters: []types.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + { + ClusterName: strptr("prod-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/prod-cluster/def-456"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + }, + }, + expected: []types.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + { + ClusterName: strptr("prod-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/prod-cluster/def-456"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + }, + }, + { + name: "SingleCluster", + mskData: &mskDataStore{ + region: "us-east-1", + clusters: []types.Cluster{ + { + ClusterName: strptr("single-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-east-1:123456789012:cluster/single-cluster/xyz-789"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + }, + }, + expected: []types.Cluster{ + { + ClusterName: strptr("single-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-east-1:123456789012:cluster/single-cluster/xyz-789"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + }, + }, + { + name: "NoClusters", + mskData: &mskDataStore{ + region: "us-east-1", + clusters: []types.Cluster{}, + }, + expected: nil, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockMSKClient(tt.mskData) + + d := &MSKDiscovery{ + msk: client, + cfg: &MSKSDConfig{ + Region: tt.mskData.region, + }, + } + + clusters, err := d.listClusters(ctx) + require.NoError(t, err) + require.Equal(t, tt.expected, clusters) + }) + } +} + +func TestMSKDiscoveryDescribeClusters(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + mskData *mskDataStore + clusterARNs []string + expected []types.Cluster + }{ + { + name: "SingleCluster", + mskData: &mskDataStore{ + region: "us-west-2", + clusters: []types.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + CurrentVersion: strptr("1.2.3"), + Tags: map[string]string{ + "Environment": "production", + "Team": "platform", + }, + }, + }, + }, + clusterARNs: []string{"arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"}, + expected: []types.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + CurrentVersion: strptr("1.2.3"), + Tags: map[string]string{ + "Environment": "production", + "Team": "platform", + }, + }, + }, + }, + { + name: "MultipleClusters", + mskData: &mskDataStore{ + region: "us-east-1", + clusters: []types.Cluster{ + { + ClusterName: strptr("cluster-1"), + ClusterArn: strptr("arn:aws:kafka:us-east-1:123456789012:cluster/cluster-1/xyz-789"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + { + ClusterName: strptr("cluster-2"), + ClusterArn: strptr("arn:aws:kafka:us-east-1:123456789012:cluster/cluster-2/def-456"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + Tags: map[string]string{ + "Stage": "prod", + }, + }, + }, + }, + clusterARNs: []string{ + "arn:aws:kafka:us-east-1:123456789012:cluster/cluster-1/xyz-789", + "arn:aws:kafka:us-east-1:123456789012:cluster/cluster-2/def-456", + }, + expected: []types.Cluster{ + { + ClusterName: strptr("cluster-1"), + ClusterArn: strptr("arn:aws:kafka:us-east-1:123456789012:cluster/cluster-1/xyz-789"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + }, + { + ClusterName: strptr("cluster-2"), + ClusterArn: strptr("arn:aws:kafka:us-east-1:123456789012:cluster/cluster-2/def-456"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + Tags: map[string]string{ + "Stage": "prod", + }, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockMSKClient(tt.mskData) + + d := &MSKDiscovery{ + msk: client, + cfg: &MSKSDConfig{ + Region: tt.mskData.region, + RequestConcurrency: 10, + }, + } + + clusters, err := d.describeClusters(ctx, tt.clusterARNs) + require.NoError(t, err) + + // Sort clusters by ARN to handle non-deterministic ordering from goroutines + sort.Slice(clusters, func(i, j int) bool { + return aws.ToString(clusters[i].ClusterArn) < aws.ToString(clusters[j].ClusterArn) + }) + sort.Slice(tt.expected, func(i, j int) bool { + return aws.ToString(tt.expected[i].ClusterArn) < aws.ToString(tt.expected[j].ClusterArn) + }) + + require.Equal(t, tt.expected, clusters) + }) + } +} + +func TestMSKDiscoveryListNodes(t *testing.T) { + ctx := context.Background() + + for _, tt := range []struct { + name string + mskData *mskDataStore + clusters []types.Cluster + expected map[string][]types.NodeInfo + }{ + { + name: "ClusterWithBrokers", + mskData: &mskDataStore{ + region: "us-west-2", + nodes: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + AddedToClusterTime: strptr("2023-01-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(1), + ClientSubnet: strptr("subnet-12345"), + ClientVpcIpAddress: strptr("10.0.1.100"), + Endpoints: []string{"b-1.test-cluster.abc123.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-12345"), + }, + }, + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-2"), + AddedToClusterTime: strptr("2023-01-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(2), + ClientSubnet: strptr("subnet-67890"), + ClientVpcIpAddress: strptr("10.0.1.101"), + Endpoints: []string{"b-2.test-cluster.abc123.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-67890"), + }, + }, + }, + }, + }, + clusters: []types.Cluster{ + { + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"), + }, + }, + expected: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + AddedToClusterTime: strptr("2023-01-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(1), + ClientSubnet: strptr("subnet-12345"), + ClientVpcIpAddress: strptr("10.0.1.100"), + Endpoints: []string{"b-1.test-cluster.abc123.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-12345"), + }, + }, + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-2"), + AddedToClusterTime: strptr("2023-01-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(2), + ClientSubnet: strptr("subnet-67890"), + ClientVpcIpAddress: strptr("10.0.1.101"), + Endpoints: []string{"b-2.test-cluster.abc123.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-67890"), + }, + }, + }, + }, + }, + { + name: "ClusterWithNoNodes", + mskData: &mskDataStore{ + region: "us-west-2", + nodes: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/empty-cluster/xyz-789": {}, + }, + }, + clusters: []types.Cluster{ + { + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/empty-cluster/xyz-789"), + }, + }, + expected: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/empty-cluster/xyz-789": nil, + }, + }, + { + name: "MultipleClusters", + mskData: &mskDataStore{ + region: "us-west-2", + nodes: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/cluster-1/abc-123": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(1), + }, + }, + }, + "arn:aws:kafka:us-west-2:123456789012:cluster/cluster-2/def-456": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-2"), + InstanceType: strptr("kafka.m5.xlarge"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(2), + }, + }, + }, + }, + }, + clusters: []types.Cluster{ + { + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/cluster-1/abc-123"), + }, + { + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/cluster-2/def-456"), + }, + }, + expected: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/cluster-1/abc-123": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(1), + }, + }, + }, + "arn:aws:kafka:us-west-2:123456789012:cluster/cluster-2/def-456": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-2"), + InstanceType: strptr("kafka.m5.xlarge"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(2), + }, + }, + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + client := newMockMSKClient(tt.mskData) + + d := &MSKDiscovery{ + msk: client, + cfg: &MSKSDConfig{ + Region: tt.mskData.region, + RequestConcurrency: 10, + }, + } + + nodes, err := d.listNodes(ctx, tt.clusters) + require.NoError(t, err) + require.Equal(t, tt.expected, nodes) + }) + } +} + +func TestMSKDiscoveryRefresh(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + mskData *mskDataStore + config *MSKSDConfig + expected []*targetgroup.Group + }{ + { + name: "ClusterWithBrokersUsingClustersConfig", + mskData: &mskDataStore{ + region: "us-west-2", + clusters: []types.Cluster{ + { + ClusterName: strptr("test-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + CurrentVersion: strptr("1.2.3"), + Tags: map[string]string{ + "Environment": "production", + "Team": "platform", + }, + Provisioned: &types.Provisioned{ + CurrentBrokerSoftwareInfo: &types.BrokerSoftwareInfo{ + ConfigurationArn: strptr("arn:aws:kafka:us-west-2:123456789012:configuration/my-config/abc-123"), + ConfigurationRevision: aws.Int64(1), + KafkaVersion: strptr("2.8.1"), + }, + OpenMonitoring: &types.OpenMonitoringInfo{ + Prometheus: &types.PrometheusInfo{ + JmxExporter: &types.JmxExporterInfo{ + EnabledInBroker: aws.Bool(true), + }, + NodeExporter: &types.NodeExporterInfo{ + EnabledInBroker: aws.Bool(true), + }, + }, + }, + }, + }, + }, + nodes: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + AddedToClusterTime: strptr("2023-01-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(1), + ClientSubnet: strptr("subnet-12345"), + ClientVpcIpAddress: strptr("10.0.1.100"), + Endpoints: []string{"b-1.test-cluster.abc123.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-12345"), + }, + }, + }, + }, + }, + config: &MSKSDConfig{ + Region: "us-west-2", + Port: 80, + RequestConcurrency: 10, + Clusters: []string{"arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"}, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("b-1.test-cluster.abc123.kafka.us-west-2.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("test-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:cluster/test-cluster/abc-123"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("1.2.3"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:configuration/my-config/abc-123"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("2.8.1"), + "__meta_msk_cluster_tag_Environment": model.LabelValue("production"), + "__meta_msk_cluster_tag_Team": model.LabelValue("platform"), + "__meta_msk_node_type": model.LabelValue("BROKER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + "__meta_msk_node_added_time": model.LabelValue("2023-01-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_node_attached_eni": model.LabelValue("eni-12345"), + "__meta_msk_broker_id": model.LabelValue("1"), + "__meta_msk_broker_client_subnet": model.LabelValue("subnet-12345"), + "__meta_msk_broker_client_vpc_ip": model.LabelValue("10.0.1.100"), + "__meta_msk_broker_node_exporter_enabled": model.LabelValue("true"), + "__meta_msk_broker_endpoint_index": model.LabelValue("0"), + }, + }, + }, + }, + }, + { + name: "NoClustersWithEmptyClustersConfig", + mskData: &mskDataStore{ + region: "us-east-1", + clusters: []types.Cluster{}, + }, + config: &MSKSDConfig{ + Region: "us-east-1", + Port: 80, + RequestConcurrency: 10, + Clusters: []string{}, // Empty clusters list uses listClusters + }, + expected: []*targetgroup.Group{ + { + Source: "us-east-1", + }, + }, + }, + { + name: "ClusterWithBrokersUsingListClusters", + mskData: &mskDataStore{ + region: "us-west-2", + clusters: []types.Cluster{ + { + ClusterName: strptr("auto-discovered-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/auto-discovered-cluster/xyz-123"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + CurrentVersion: strptr("1.0.0"), + Provisioned: &types.Provisioned{ + CurrentBrokerSoftwareInfo: &types.BrokerSoftwareInfo{ + ConfigurationArn: strptr("arn:aws:kafka:us-west-2:123456789012:configuration/config/xyz"), + ConfigurationRevision: aws.Int64(1), + KafkaVersion: strptr("3.3.1"), + }, + OpenMonitoring: &types.OpenMonitoringInfo{ + Prometheus: &types.PrometheusInfo{ + JmxExporter: &types.JmxExporterInfo{ + EnabledInBroker: aws.Bool(true), + }, + NodeExporter: &types.NodeExporterInfo{ + EnabledInBroker: aws.Bool(true), + }, + }, + }, + }, + }, + }, + nodes: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/auto-discovered-cluster/xyz-123": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-auto"), + AddedToClusterTime: strptr("2023-01-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(1), + ClientSubnet: strptr("subnet-auto"), + ClientVpcIpAddress: strptr("10.0.1.200"), + Endpoints: []string{"b-auto.cluster.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-auto"), + }, + }, + }, + }, + }, + config: &MSKSDConfig{ + Region: "us-west-2", + Port: 80, + RequestConcurrency: 10, + Clusters: nil, // nil clusters list uses listClusters (backward compatibility) + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("b-auto.cluster.kafka.us-west-2.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("auto-discovered-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:cluster/auto-discovered-cluster/xyz-123"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("1.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:configuration/config/xyz"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.3.1"), + "__meta_msk_node_type": model.LabelValue("BROKER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:node/broker-auto"), + "__meta_msk_node_added_time": model.LabelValue("2023-01-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_node_attached_eni": model.LabelValue("eni-auto"), + "__meta_msk_broker_id": model.LabelValue("1"), + "__meta_msk_broker_client_subnet": model.LabelValue("subnet-auto"), + "__meta_msk_broker_client_vpc_ip": model.LabelValue("10.0.1.200"), + "__meta_msk_broker_node_exporter_enabled": model.LabelValue("true"), + "__meta_msk_broker_endpoint_index": model.LabelValue("0"), + }, + }, + }, + }, + }, + { + name: "ClusterWithBrokersAndControllersUsingClustersConfig", + mskData: &mskDataStore{ + region: "us-west-2", + clusters: []types.Cluster{ + { + ClusterName: strptr("kraft-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-west-2:123456789012:cluster/kraft-cluster/xyz-789"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + CurrentVersion: strptr("1.0.0"), + Tags: map[string]string{ + "Type": "kraft", + }, + Provisioned: &types.Provisioned{ + CurrentBrokerSoftwareInfo: &types.BrokerSoftwareInfo{ + ConfigurationArn: strptr("arn:aws:kafka:us-west-2:123456789012:configuration/config/xyz"), + ConfigurationRevision: aws.Int64(2), + KafkaVersion: strptr("3.3.1"), + }, + OpenMonitoring: &types.OpenMonitoringInfo{ + Prometheus: &types.PrometheusInfo{ + JmxExporter: &types.JmxExporterInfo{ + EnabledInBroker: aws.Bool(true), + }, + NodeExporter: &types.NodeExporterInfo{ + EnabledInBroker: aws.Bool(false), + }, + }, + }, + }, + }, + }, + nodes: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-west-2:123456789012:cluster/kraft-cluster/xyz-789": { + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + AddedToClusterTime: strptr("2023-06-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(1), + ClientSubnet: strptr("subnet-abc123"), + ClientVpcIpAddress: strptr("10.0.2.100"), + Endpoints: []string{"b-1.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-broker-1"), + }, + }, + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/broker-2"), + AddedToClusterTime: strptr("2023-06-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(2), + ClientSubnet: strptr("subnet-abc124"), + ClientVpcIpAddress: strptr("10.0.2.101"), + Endpoints: []string{"b-2.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com"}, + AttachedENIId: strptr("eni-broker-2"), + }, + }, + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/controller-1"), + AddedToClusterTime: strptr("2023-06-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + ControllerNodeInfo: &types.ControllerNodeInfo{ + Endpoints: []string{"c-1.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com"}, + }, + }, + { + NodeARN: strptr("arn:aws:kafka:us-west-2:123456789012:node/controller-2"), + AddedToClusterTime: strptr("2023-06-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + ControllerNodeInfo: &types.ControllerNodeInfo{ + Endpoints: []string{"c-2.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com"}, + }, + }, + }, + }, + }, + config: &MSKSDConfig{ + Region: "us-west-2", + Port: 80, + RequestConcurrency: 10, + Clusters: []string{"arn:aws:kafka:us-west-2:123456789012:cluster/kraft-cluster/xyz-789"}, + }, + expected: []*targetgroup.Group{ + { + Source: "us-west-2", + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue("b-1.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("kraft-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:cluster/kraft-cluster/xyz-789"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("1.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:configuration/config/xyz"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("2"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.3.1"), + "__meta_msk_cluster_tag_Type": model.LabelValue("kraft"), + "__meta_msk_node_type": model.LabelValue("BROKER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:node/broker-1"), + "__meta_msk_node_added_time": model.LabelValue("2023-06-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_node_attached_eni": model.LabelValue("eni-broker-1"), + "__meta_msk_broker_id": model.LabelValue("1"), + "__meta_msk_broker_client_subnet": model.LabelValue("subnet-abc123"), + "__meta_msk_broker_client_vpc_ip": model.LabelValue("10.0.2.100"), + "__meta_msk_broker_node_exporter_enabled": model.LabelValue("false"), + "__meta_msk_broker_endpoint_index": model.LabelValue("0"), + }, + { + model.AddressLabel: model.LabelValue("b-2.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("kraft-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:cluster/kraft-cluster/xyz-789"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("1.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:configuration/config/xyz"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("2"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.3.1"), + "__meta_msk_cluster_tag_Type": model.LabelValue("kraft"), + "__meta_msk_node_type": model.LabelValue("BROKER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:node/broker-2"), + "__meta_msk_node_added_time": model.LabelValue("2023-06-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_node_attached_eni": model.LabelValue("eni-broker-2"), + "__meta_msk_broker_id": model.LabelValue("2"), + "__meta_msk_broker_client_subnet": model.LabelValue("subnet-abc124"), + "__meta_msk_broker_client_vpc_ip": model.LabelValue("10.0.2.101"), + "__meta_msk_broker_node_exporter_enabled": model.LabelValue("false"), + "__meta_msk_broker_endpoint_index": model.LabelValue("0"), + }, + { + model.AddressLabel: model.LabelValue("c-1.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("kraft-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:cluster/kraft-cluster/xyz-789"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("1.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:configuration/config/xyz"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("2"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.3.1"), + "__meta_msk_cluster_tag_Type": model.LabelValue("kraft"), + "__meta_msk_node_type": model.LabelValue("CONTROLLER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:node/controller-1"), + "__meta_msk_node_added_time": model.LabelValue("2023-06-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_controller_endpoint_index": model.LabelValue("0"), + }, + { + model.AddressLabel: model.LabelValue("c-2.kraft-cluster.xyz789.kafka.us-west-2.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("kraft-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:cluster/kraft-cluster/xyz-789"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("1.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:configuration/config/xyz"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("2"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.3.1"), + "__meta_msk_cluster_tag_Type": model.LabelValue("kraft"), + "__meta_msk_node_type": model.LabelValue("CONTROLLER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-west-2:123456789012:node/controller-2"), + "__meta_msk_node_added_time": model.LabelValue("2023-06-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_controller_endpoint_index": model.LabelValue("0"), + }, + }, + }, + }, + }, + { + name: "NodesWithMultipleEndpointsUsingClustersConfig", + mskData: &mskDataStore{ + region: "us-east-1", + clusters: []types.Cluster{ + { + ClusterName: strptr("multi-endpoint-cluster"), + ClusterArn: strptr("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + State: types.ClusterStateActive, + ClusterType: types.ClusterTypeProvisioned, + CurrentVersion: strptr("2.0.0"), + Provisioned: &types.Provisioned{ + CurrentBrokerSoftwareInfo: &types.BrokerSoftwareInfo{ + ConfigurationArn: strptr("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + ConfigurationRevision: aws.Int64(1), + KafkaVersion: strptr("3.4.0"), + }, + OpenMonitoring: &types.OpenMonitoringInfo{ + Prometheus: &types.PrometheusInfo{ + JmxExporter: &types.JmxExporterInfo{ + EnabledInBroker: aws.Bool(true), + }, + NodeExporter: &types.NodeExporterInfo{ + EnabledInBroker: aws.Bool(true), + }, + }, + }, + }, + }, + }, + nodes: map[string][]types.NodeInfo{ + "arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999": { + { + NodeARN: strptr("arn:aws:kafka:us-east-1:123456789012:node/broker-multi"), + AddedToClusterTime: strptr("2023-08-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.xlarge"), + BrokerNodeInfo: &types.BrokerNodeInfo{ + BrokerId: aws.Float64(3), + ClientSubnet: strptr("subnet-multi-1"), + ClientVpcIpAddress: strptr("10.0.3.50"), + // Multiple endpoints for this broker + Endpoints: []string{"b-3-1.cluster.kafka.us-east-1.amazonaws.com", "b-3-2.cluster.kafka.us-east-1.amazonaws.com", "b-3-3.cluster.kafka.us-east-1.amazonaws.com"}, + AttachedENIId: strptr("eni-multi-broker"), + }, + }, + { + NodeARN: strptr("arn:aws:kafka:us-east-1:123456789012:node/controller-multi"), + AddedToClusterTime: strptr("2023-08-01T00:00:00Z"), + InstanceType: strptr("kafka.m5.large"), + ControllerNodeInfo: &types.ControllerNodeInfo{ + // Multiple endpoints for this controller + Endpoints: []string{"c-1-1.cluster.kafka.us-east-1.amazonaws.com", "c-1-2.cluster.kafka.us-east-1.amazonaws.com", "c-1-3.cluster.kafka.us-east-1.amazonaws.com", "c-1-4.cluster.kafka.us-east-1.amazonaws.com"}, + }, + }, + }, + }, + }, + config: &MSKSDConfig{ + Region: "us-east-1", + Port: 80, + RequestConcurrency: 10, + Clusters: []string{"arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"}, + }, + expected: []*targetgroup.Group{ + { + Source: "us-east-1", + Targets: []model.LabelSet{ + // Broker with 3 endpoints - creates 3 targets with different endpoint indices + { + model.AddressLabel: model.LabelValue("b-3-1.cluster.kafka.us-east-1.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("multi-endpoint-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("2.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.4.0"), + "__meta_msk_node_type": model.LabelValue("BROKER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:node/broker-multi"), + "__meta_msk_node_added_time": model.LabelValue("2023-08-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.xlarge"), + "__meta_msk_node_attached_eni": model.LabelValue("eni-multi-broker"), + "__meta_msk_broker_id": model.LabelValue("3"), + "__meta_msk_broker_client_subnet": model.LabelValue("subnet-multi-1"), + "__meta_msk_broker_client_vpc_ip": model.LabelValue("10.0.3.50"), + "__meta_msk_broker_node_exporter_enabled": model.LabelValue("true"), + "__meta_msk_broker_endpoint_index": model.LabelValue("0"), + }, + { + model.AddressLabel: model.LabelValue("b-3-2.cluster.kafka.us-east-1.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("multi-endpoint-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("2.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.4.0"), + "__meta_msk_node_type": model.LabelValue("BROKER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:node/broker-multi"), + "__meta_msk_node_added_time": model.LabelValue("2023-08-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.xlarge"), + "__meta_msk_node_attached_eni": model.LabelValue("eni-multi-broker"), + "__meta_msk_broker_id": model.LabelValue("3"), + "__meta_msk_broker_client_subnet": model.LabelValue("subnet-multi-1"), + "__meta_msk_broker_client_vpc_ip": model.LabelValue("10.0.3.50"), + "__meta_msk_broker_node_exporter_enabled": model.LabelValue("true"), + "__meta_msk_broker_endpoint_index": model.LabelValue("1"), + }, + { + model.AddressLabel: model.LabelValue("b-3-3.cluster.kafka.us-east-1.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("multi-endpoint-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("2.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.4.0"), + "__meta_msk_node_type": model.LabelValue("BROKER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:node/broker-multi"), + "__meta_msk_node_added_time": model.LabelValue("2023-08-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.xlarge"), + "__meta_msk_node_attached_eni": model.LabelValue("eni-multi-broker"), + "__meta_msk_broker_id": model.LabelValue("3"), + "__meta_msk_broker_client_subnet": model.LabelValue("subnet-multi-1"), + "__meta_msk_broker_client_vpc_ip": model.LabelValue("10.0.3.50"), + "__meta_msk_broker_node_exporter_enabled": model.LabelValue("true"), + "__meta_msk_broker_endpoint_index": model.LabelValue("2"), + }, + // Controller with 4 endpoints - creates 4 targets with different endpoint indices + { + model.AddressLabel: model.LabelValue("c-1-1.cluster.kafka.us-east-1.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("multi-endpoint-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("2.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.4.0"), + "__meta_msk_node_type": model.LabelValue("CONTROLLER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:node/controller-multi"), + "__meta_msk_node_added_time": model.LabelValue("2023-08-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_controller_endpoint_index": model.LabelValue("0"), + }, + { + model.AddressLabel: model.LabelValue("c-1-2.cluster.kafka.us-east-1.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("multi-endpoint-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("2.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.4.0"), + "__meta_msk_node_type": model.LabelValue("CONTROLLER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:node/controller-multi"), + "__meta_msk_node_added_time": model.LabelValue("2023-08-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_controller_endpoint_index": model.LabelValue("1"), + }, + { + model.AddressLabel: model.LabelValue("c-1-3.cluster.kafka.us-east-1.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("multi-endpoint-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("2.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.4.0"), + "__meta_msk_node_type": model.LabelValue("CONTROLLER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:node/controller-multi"), + "__meta_msk_node_added_time": model.LabelValue("2023-08-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_controller_endpoint_index": model.LabelValue("2"), + }, + { + model.AddressLabel: model.LabelValue("c-1-4.cluster.kafka.us-east-1.amazonaws.com:80"), + "__meta_msk_cluster_name": model.LabelValue("multi-endpoint-cluster"), + "__meta_msk_cluster_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:cluster/multi-endpoint-cluster/abc-999"), + "__meta_msk_cluster_state": model.LabelValue("ACTIVE"), + "__meta_msk_cluster_type": model.LabelValue("PROVISIONED"), + "__meta_msk_cluster_version": model.LabelValue("2.0.0"), + "__meta_msk_cluster_jmx_exporter_enabled": model.LabelValue("true"), + "__meta_msk_cluster_configuration_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:configuration/config/abc"), + "__meta_msk_cluster_configuration_revision": model.LabelValue("1"), + "__meta_msk_cluster_kafka_version": model.LabelValue("3.4.0"), + "__meta_msk_node_type": model.LabelValue("CONTROLLER"), + "__meta_msk_node_arn": model.LabelValue("arn:aws:kafka:us-east-1:123456789012:node/controller-multi"), + "__meta_msk_node_added_time": model.LabelValue("2023-08-01T00:00:00Z"), + "__meta_msk_node_instance_type": model.LabelValue("kafka.m5.large"), + "__meta_msk_controller_endpoint_index": model.LabelValue("3"), + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newMockMSKClient(tt.mskData) + + config := tt.config + if config == nil { + // Default config for backward compatibility + config = &MSKSDConfig{ + Region: tt.mskData.region, + Port: 80, + RequestConcurrency: 10, + } + } + + d := &MSKDiscovery{ + msk: client, + cfg: config, + } + + groups, err := d.refresh(ctx) + require.NoError(t, err) + + // Sort targets within each group by address to handle non-deterministic ordering from goroutines + for _, group := range groups { + if len(group.Targets) > 0 { + sort.Slice(group.Targets, func(i, j int) bool { + return string(group.Targets[i][model.AddressLabel]) < string(group.Targets[j][model.AddressLabel]) + }) + } + } + for _, group := range tt.expected { + if len(group.Targets) > 0 { + sort.Slice(group.Targets, func(i, j int) bool { + return string(group.Targets[i][model.AddressLabel]) < string(group.Targets[j][model.AddressLabel]) + }) + } + } + + require.Equal(t, tt.expected, groups) + }) + } +} + +func TestNodeType(t *testing.T) { + tests := []struct { + name string + node types.NodeInfo + expected NodeType + }{ + { + name: "BrokerNode", + node: types.NodeInfo{ + BrokerNodeInfo: &types.BrokerNodeInfo{}, + }, + expected: NodeTypeBroker, + }, + { + name: "ControllerNode", + node: types.NodeInfo{ + ControllerNodeInfo: &types.ControllerNodeInfo{}, + }, + expected: NodeTypeController, + }, + { + name: "UnknownNode", + node: types.NodeInfo{}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := nodeType(tt.node) + require.Equal(t, tt.expected, result) + }) + } +} + +// MSK client mock. +type mockMSKClient struct { + mskData mskDataStore +} + +func newMockMSKClient(mskData *mskDataStore) *mockMSKClient { + return &mockMSKClient{ + mskData: *mskData, + } +} + +func (m *mockMSKClient) DescribeClusterV2(_ context.Context, input *kafka.DescribeClusterV2Input, _ ...func(*kafka.Options)) (*kafka.DescribeClusterV2Output, error) { + inputARN := aws.ToString(input.ClusterArn) + for i := range m.mskData.clusters { + cluster := &m.mskData.clusters[i] + if aws.ToString(cluster.ClusterArn) == inputARN { + return &kafka.DescribeClusterV2Output{ + ClusterInfo: cluster, + }, nil + } + } + + return nil, fmt.Errorf("cluster not found: %s", inputARN) +} + +func (m *mockMSKClient) ListClustersV2(_ context.Context, input *kafka.ListClustersV2Input, _ ...func(*kafka.Options)) (*kafka.ListClustersV2Output, error) { + var clusters []types.Cluster + + for _, cluster := range m.mskData.clusters { + // Apply cluster name filter if specified + if input.ClusterNameFilter != nil && *input.ClusterNameFilter != "" { + if cluster.ClusterName != nil && *cluster.ClusterName != *input.ClusterNameFilter { + continue + } + } + + // Apply cluster type filter if specified + if input.ClusterTypeFilter != nil && *input.ClusterTypeFilter != "" { + if string(cluster.ClusterType) != *input.ClusterTypeFilter { + continue + } + } + + clusters = append(clusters, cluster) + } + + return &kafka.ListClustersV2Output{ + ClusterInfoList: clusters, + }, nil +} + +func (m *mockMSKClient) ListNodes(_ context.Context, input *kafka.ListNodesInput, _ ...func(*kafka.Options)) (*kafka.ListNodesOutput, error) { + clusterARN := aws.ToString(input.ClusterArn) + nodes, exists := m.mskData.nodes[clusterARN] + if !exists { + return &kafka.ListNodesOutput{ + NodeInfoList: nil, + }, nil + } + + return &kafka.ListNodesOutput{ + NodeInfoList: nodes, + }, nil +} diff --git a/discovery/azure/azure.go b/discovery/azure/azure.go index bed4861787..32fc97fdfa 100644 --- a/discovery/azure/azure.go +++ b/discovery/azure/azure.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -127,7 +127,7 @@ func (*SDConfig) Name() string { return "azure" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } func validateAuthParam(param, name string) error { @@ -178,28 +178,29 @@ type Discovery struct { } // NewDiscovery returns a new AzureDiscovery which periodically refreshes its targets. -func NewDiscovery(cfg *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*azureMetrics) +func NewDiscovery(cfg *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*azureMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - if logger == nil { - logger = promslog.NewNopLogger() + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() } l := cache.New(cache.AsLRU[string, *armnetwork.Interface](lru.WithCapacity(5000))) d := &Discovery{ cfg: cfg, port: cfg.Port, - logger: logger, + logger: opts.Logger, cache: l, metrics: m, } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "azure", + SetName: opts.SetName, Interval: time.Duration(cfg.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/azure/azure_test.go b/discovery/azure/azure_test.go index a88738da9f..23c120ac6b 100644 --- a/discovery/azure/azure_test.go +++ b/discovery/azure/azure_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -659,7 +659,11 @@ func TestAzureRefresh(t *testing.T) { refreshMetrics := discovery.NewRefreshMetrics(reg) metrics := azureSDConfig.NewDiscovererMetrics(reg, refreshMetrics) - sd, err := NewDiscovery(azureSDConfig, nil, metrics) + sd, err := NewDiscovery(azureSDConfig, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "azure", + }) require.NoError(t, err) tg, err := sd.refreshAzureClient(context.Background(), azureClient) diff --git a/discovery/azure/metrics.go b/discovery/azure/metrics.go index 3e3dbdbfbb..dc0291cdb8 100644 --- a/discovery/azure/metrics.go +++ b/discovery/azure/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/consul/consul.go b/discovery/consul/consul.go index 600bd274a4..4cc4003b2c 100644 --- a/discovery/consul/consul.go +++ b/discovery/consul/consul.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/consul/consul_test.go b/discovery/consul/consul_test.go index b813146089..32394dcc00 100644 --- a/discovery/consul/consul_test.go +++ b/discovery/consul/consul_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/consul/metrics.go b/discovery/consul/metrics.go index b49509bd8f..903fba5cef 100644 --- a/discovery/consul/metrics.go +++ b/discovery/consul/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/digitalocean/digitalocean.go b/discovery/digitalocean/digitalocean.go index 5c9795440d..0a185c2915 100644 --- a/discovery/digitalocean/digitalocean.go +++ b/discovery/digitalocean/digitalocean.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net" "net/http" "strconv" @@ -84,7 +83,7 @@ func (*SDConfig) Name() string { return "digitalocean" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -112,8 +111,8 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*digitaloceanMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*digitaloceanMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -140,8 +139,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "digitalocean", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/digitalocean/digitalocean_test.go b/discovery/digitalocean/digitalocean_test.go index a282225ac2..560d8d533a 100644 --- a/discovery/digitalocean/digitalocean_test.go +++ b/discovery/digitalocean/digitalocean_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -57,7 +57,11 @@ func TestDigitalOceanSDRefresh(t *testing.T) { defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "digitalocean", + }) require.NoError(t, err) endpoint, err := url.Parse(sdmock.Mock.Endpoint()) require.NoError(t, err) diff --git a/discovery/digitalocean/metrics.go b/discovery/digitalocean/metrics.go index 7f68b39e56..4b11b825e5 100644 --- a/discovery/digitalocean/metrics.go +++ b/discovery/digitalocean/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/digitalocean/mock_test.go b/discovery/digitalocean/mock_test.go index 62d963c3b3..d5703d7702 100644 --- a/discovery/digitalocean/mock_test.go +++ b/discovery/digitalocean/mock_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/discoverer_metrics_noop.go b/discovery/discoverer_metrics_noop.go index 4321204b6c..b75474dfec 100644 --- a/discovery/discoverer_metrics_noop.go +++ b/discovery/discoverer_metrics_noop.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/discovery.go b/discovery/discovery.go index 2157b820b9..c4f8c8d458 100644 --- a/discovery/discovery.go +++ b/discovery/discovery.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -54,19 +54,23 @@ type DiscovererOptions struct { // Extra HTTP client options to expose to Discoverers. This field may be // ignored; Discoverer implementations must opt-in to reading it. HTTPClientOptions []config.HTTPClientOption + + // SetName identifies this discoverer set. + SetName string } // RefreshMetrics are used by the "refresh" package. // We define them here in the "discovery" package in order to avoid a cyclic dependency between // "discovery" and "refresh". type RefreshMetrics struct { - Failures prometheus.Counter - Duration prometheus.Observer + Failures prometheus.Counter + Duration prometheus.Observer + DurationHistogram prometheus.Observer } // RefreshMetricsInstantiator instantiates the metrics used by the "refresh" package. type RefreshMetricsInstantiator interface { - Instantiate(mech string) *RefreshMetrics + Instantiate(mech, setName string) *RefreshMetrics } // RefreshMetricsManager is an interface for registering, unregistering, and diff --git a/discovery/discovery_test.go b/discovery/discovery_test.go index 116095fd62..53539b6d40 100644 --- a/discovery/discovery_test.go +++ b/discovery/discovery_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/dns/dns.go b/discovery/dns/dns.go index 24af8f65d9..4d9200d734 100644 --- a/discovery/dns/dns.go +++ b/discovery/dns/dns.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -78,7 +78,7 @@ func (*SDConfig) Name() string { return "dns" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(*c, opts.Logger, opts.Metrics) + return NewDiscovery(*c, opts) } // UnmarshalYAML implements the yaml.Unmarshaler interface. @@ -118,14 +118,14 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*dnsMetrics) +func NewDiscovery(conf SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*dnsMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - if logger == nil { - logger = promslog.NewNopLogger() + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() } qtype := dns.TypeSRV @@ -145,15 +145,16 @@ func NewDiscovery(conf SDConfig, logger *slog.Logger, metrics discovery.Discover names: conf.Names, qtype: qtype, port: conf.Port, - logger: logger, + logger: opts.Logger, lookupFn: lookupWithSearchPath, metrics: m, } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "dns", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/dns/dns_test.go b/discovery/dns/dns_test.go index eb37f1a98e..eeb1137878 100644 --- a/discovery/dns/dns_test.go +++ b/discovery/dns/dns_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -259,7 +259,11 @@ func TestDNS(t *testing.T) { metrics := tc.config.NewDiscovererMetrics(reg, refreshMetrics) require.NoError(t, metrics.Register()) - sd, err := NewDiscovery(tc.config, nil, metrics) + sd, err := NewDiscovery(tc.config, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "dns", + }) require.NoError(t, err) sd.lookupFn = tc.lookup diff --git a/discovery/dns/metrics.go b/discovery/dns/metrics.go index 27c96b53e0..b65db5e6c0 100644 --- a/discovery/dns/metrics.go +++ b/discovery/dns/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/eureka/client.go b/discovery/eureka/client.go index e4b54faae6..252b152637 100644 --- a/discovery/eureka/client.go +++ b/discovery/eureka/client.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/eureka/client_test.go b/discovery/eureka/client_test.go index f85409a11e..19812b1f5d 100644 --- a/discovery/eureka/client_test.go +++ b/discovery/eureka/client_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/eureka/eureka.go b/discovery/eureka/eureka.go index 11e83359cf..0d46667437 100644 --- a/discovery/eureka/eureka.go +++ b/discovery/eureka/eureka.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,7 +16,6 @@ package eureka import ( "context" "errors" - "log/slog" "net" "net/http" "net/url" @@ -88,7 +87,7 @@ func (*SDConfig) Name() string { return "eureka" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -125,8 +124,8 @@ type Discovery struct { } // NewDiscovery creates a new Eureka discovery for the given role. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*eurekaMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*eurekaMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -142,8 +141,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "eureka", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/eureka/eureka_test.go b/discovery/eureka/eureka_test.go index 5ea9a6c74e..69612fedb7 100644 --- a/discovery/eureka/eureka_test.go +++ b/discovery/eureka/eureka_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -47,7 +47,11 @@ func testUpdateServices(respHandler http.HandlerFunc) ([]*targetgroup.Group, err defer metrics.Unregister() defer refreshMetrics.Unregister() - md, err := NewDiscovery(&conf, nil, metrics) + md, err := NewDiscovery(&conf, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "eureka", + }) if err != nil { return nil, err } diff --git a/discovery/eureka/metrics.go b/discovery/eureka/metrics.go index 72cfe47096..5a0720a8d5 100644 --- a/discovery/eureka/metrics.go +++ b/discovery/eureka/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/file/file.go b/discovery/file/file.go index e0225891ce..c654297e0a 100644 --- a/discovery/file/file.go +++ b/discovery/file/file.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/file/file_test.go b/discovery/file/file_test.go index c80744f8c3..d8a36df399 100644 --- a/discovery/file/file_test.go +++ b/discovery/file/file_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/file/metrics.go b/discovery/file/metrics.go index 3e3df7bbf6..0371338d46 100644 --- a/discovery/file/metrics.go +++ b/discovery/file/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/gce/gce.go b/discovery/gce/gce.go index f5d20fb740..96eed2b27b 100644 --- a/discovery/gce/gce.go +++ b/discovery/gce/gce.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net/http" "strconv" "strings" @@ -94,7 +93,7 @@ func (*SDConfig) Name() string { return "gce" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(*c, opts.Logger, opts.Metrics) + return NewDiscovery(*c, opts) } // UnmarshalYAML implements the yaml.Unmarshaler interface. @@ -129,8 +128,8 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*gceMetrics) +func NewDiscovery(conf SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*gceMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -155,8 +154,9 @@ func NewDiscovery(conf SDConfig, logger *slog.Logger, metrics discovery.Discover d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "gce", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/gce/metrics.go b/discovery/gce/metrics.go index 7ea69b1a89..c4020f0a53 100644 --- a/discovery/gce/metrics.go +++ b/discovery/gce/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/hetzner/hcloud.go b/discovery/hetzner/hcloud.go index 88fe09bd3e..7fe55ffded 100644 --- a/discovery/hetzner/hcloud.go +++ b/discovery/hetzner/hcloud.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -98,13 +98,13 @@ func (d *hcloudDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, er hetznerLabelRole: model.LabelValue(HetznerRoleHcloud), hetznerLabelServerID: model.LabelValue(strconv.FormatInt(server.ID, 10)), hetznerLabelServerName: model.LabelValue(server.Name), - hetznerLabelDatacenter: model.LabelValue(server.Datacenter.Name), + hetznerLabelDatacenter: model.LabelValue(server.Datacenter.Name), //nolint:staticcheck // server.Datacenter is deprecated but kept for backwards compatibility until the next minor release hetznerLabelPublicIPv4: model.LabelValue(server.PublicNet.IPv4.IP.String()), hetznerLabelPublicIPv6Network: model.LabelValue(server.PublicNet.IPv6.Network.String()), hetznerLabelServerStatus: model.LabelValue(server.Status), - hetznerLabelHcloudDatacenterLocation: model.LabelValue(server.Datacenter.Location.Name), - hetznerLabelHcloudDatacenterLocationNetworkZone: model.LabelValue(server.Datacenter.Location.NetworkZone), + hetznerLabelHcloudDatacenterLocation: model.LabelValue(server.Datacenter.Location.Name), //nolint:staticcheck // server.Datacenter is deprecated but kept for backwards compatibility until the next minor release + hetznerLabelHcloudDatacenterLocationNetworkZone: model.LabelValue(server.Datacenter.Location.NetworkZone), //nolint:staticcheck // server.Datacenter is deprecated but kept for backwards compatibility until the next minor release hetznerLabelHcloudType: model.LabelValue(server.ServerType.Name), hetznerLabelHcloudCPUCores: model.LabelValue(strconv.Itoa(server.ServerType.Cores)), hetznerLabelHcloudCPUType: model.LabelValue(server.ServerType.CPUType), diff --git a/discovery/hetzner/hcloud_test.go b/discovery/hetzner/hcloud_test.go index fa8291625a..3f20bcb86c 100644 --- a/discovery/hetzner/hcloud_test.go +++ b/discovery/hetzner/hcloud_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/hetzner/hetzner.go b/discovery/hetzner/hetzner.go index 5c5252d3d7..932cfc8c93 100644 --- a/discovery/hetzner/hetzner.go +++ b/discovery/hetzner/hetzner.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -78,7 +78,7 @@ func (*SDConfig) Name() string { return "hetzner" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } type refresher interface { @@ -138,21 +138,22 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*refresh.Discovery, error) { - m, ok := metrics.(*hetznerMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*refresh.Discovery, error) { + m, ok := opts.Metrics.(*hetznerMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - r, err := newRefresher(conf, logger) + r, err := newRefresher(conf, opts.Logger) if err != nil { return nil, err } return refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "hetzner", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: r.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/hetzner/metrics.go b/discovery/hetzner/metrics.go index 0023018194..cab1d66a3e 100644 --- a/discovery/hetzner/metrics.go +++ b/discovery/hetzner/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/hetzner/mock_test.go b/discovery/hetzner/mock_test.go index d192a4eae9..5f1e9c036b 100644 --- a/discovery/hetzner/mock_test.go +++ b/discovery/hetzner/mock_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/hetzner/robot.go b/discovery/hetzner/robot.go index 33aa2abcd8..ef5de1a30c 100644 --- a/discovery/hetzner/robot.go +++ b/discovery/hetzner/robot.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/hetzner/robot_test.go b/discovery/hetzner/robot_test.go index 2618bd097c..0e8b7954cc 100644 --- a/discovery/hetzner/robot_test.go +++ b/discovery/hetzner/robot_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/http/http.go b/discovery/http/http.go index bbaf4038c8..fa9c7208fa 100644 --- a/discovery/http/http.go +++ b/discovery/http/http.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -19,7 +19,6 @@ import ( "errors" "fmt" "io" - "log/slog" "net/http" "net/url" "strconv" @@ -69,7 +68,7 @@ func (*SDConfig) Name() string { return "http" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.HTTPClientOptions, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -115,17 +114,17 @@ type Discovery struct { } // NewDiscovery returns a new HTTP discovery for the given config. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, clientOpts []config.HTTPClientOption, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*httpMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*httpMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - if logger == nil { - logger = promslog.NewNopLogger() + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() } - client, err := config.NewClientFromConfig(conf.HTTPClientConfig, "http", clientOpts...) + client, err := config.NewClientFromConfig(conf.HTTPClientConfig, "http", opts.HTTPClientOptions...) if err != nil { return nil, err } @@ -140,8 +139,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, clientOpts []config.HTTPC d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "http", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.Refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/http/http_test.go b/discovery/http/http_test.go index 3af9e4e504..50a5800fc6 100644 --- a/discovery/http/http_test.go +++ b/discovery/http/http_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -49,7 +49,12 @@ func TestHTTPValidRefresh(t *testing.T) { require.NoError(t, metrics.Register()) defer metrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), nil, metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + HTTPClientOptions: nil, + Metrics: metrics, + SetName: "http", + }) require.NoError(t, err) ctx := context.Background() @@ -94,7 +99,12 @@ func TestHTTPInvalidCode(t *testing.T) { require.NoError(t, metrics.Register()) defer metrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), nil, metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + HTTPClientOptions: nil, + Metrics: metrics, + SetName: "http", + }) require.NoError(t, err) ctx := context.Background() @@ -123,7 +133,12 @@ func TestHTTPInvalidFormat(t *testing.T) { require.NoError(t, metrics.Register()) defer metrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), nil, metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + HTTPClientOptions: nil, + Metrics: metrics, + SetName: "http", + }) require.NoError(t, err) ctx := context.Background() @@ -442,7 +457,12 @@ func TestSourceDisappeared(t *testing.T) { require.NoError(t, metrics.Register()) defer metrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), nil, metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + HTTPClientOptions: nil, + Metrics: metrics, + SetName: "http", + }) require.NoError(t, err) for _, test := range cases { ctx := context.Background() diff --git a/discovery/http/metrics.go b/discovery/http/metrics.go index b1f8b84433..57fbcac15a 100644 --- a/discovery/http/metrics.go +++ b/discovery/http/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/install/install.go b/discovery/install/install.go index 9c397f9d36..05598347c1 100644 --- a/discovery/install/install.go +++ b/discovery/install/install.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ionos/ionos.go b/discovery/ionos/ionos.go index 021986395b..93d57654e8 100644 --- a/discovery/ionos/ionos.go +++ b/discovery/ionos/ionos.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -15,7 +15,6 @@ package ionos import ( "errors" - "log/slog" "time" "github.com/prometheus/client_golang/prometheus" @@ -42,8 +41,8 @@ func init() { type Discovery struct{} // NewDiscovery returns a new refresh.Discovery for IONOS Cloud. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*refresh.Discovery, error) { - m, ok := metrics.(*ionosMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*refresh.Discovery, error) { + m, ok := opts.Metrics.(*ionosMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -52,15 +51,16 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove conf.ionosEndpoint = "https://api.ionos.com" } - d, err := newServerDiscovery(conf, logger) + d, err := newServerDiscovery(conf, opts.Logger) if err != nil { return nil, err } return refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "ionos", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, @@ -101,8 +101,8 @@ func (SDConfig) Name() string { } // NewDiscoverer returns a new discovery.Discoverer for IONOS Cloud. -func (c SDConfig) NewDiscoverer(options discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(&c, options.Logger, options.Metrics) +func (c SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewDiscovery(&c, opts) } // UnmarshalYAML implements the yaml.Unmarshaler interface. diff --git a/discovery/ionos/metrics.go b/discovery/ionos/metrics.go index e79bded695..7fc78fdfa5 100644 --- a/discovery/ionos/metrics.go +++ b/discovery/ionos/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ionos/server.go b/discovery/ionos/server.go index 81bb497277..bd351625db 100644 --- a/discovery/ionos/server.go +++ b/discovery/ionos/server.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ionos/server_test.go b/discovery/ionos/server_test.go index 30f358e325..28fd285f67 100644 --- a/discovery/ionos/server_test.go +++ b/discovery/ionos/server_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/endpoints.go b/discovery/kubernetes/endpoints.go index 21c401da2c..4edcf9d4fa 100644 --- a/discovery/kubernetes/endpoints.go +++ b/discovery/kubernetes/endpoints.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/endpoints_test.go b/discovery/kubernetes/endpoints_test.go index aa0e432bfd..0ac472324d 100644 --- a/discovery/kubernetes/endpoints_test.go +++ b/discovery/kubernetes/endpoints_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/endpointslice.go b/discovery/kubernetes/endpointslice.go index 85b579438f..a6cfb0706a 100644 --- a/discovery/kubernetes/endpointslice.go +++ b/discovery/kubernetes/endpointslice.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/endpointslice_test.go b/discovery/kubernetes/endpointslice_test.go index cfd6be709e..b4dc0c36ce 100644 --- a/discovery/kubernetes/endpointslice_test.go +++ b/discovery/kubernetes/endpointslice_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/ingress.go b/discovery/kubernetes/ingress.go index 551453e513..985cc8f138 100644 --- a/discovery/kubernetes/ingress.go +++ b/discovery/kubernetes/ingress.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/ingress_test.go b/discovery/kubernetes/ingress_test.go index 76c9ff9036..15fa28002a 100644 --- a/discovery/kubernetes/ingress_test.go +++ b/discovery/kubernetes/ingress_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/kubernetes.go b/discovery/kubernetes/kubernetes.go index 1a6f965ecd..678f287ef5 100644 --- a/discovery/kubernetes/kubernetes.go +++ b/discovery/kubernetes/kubernetes.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/kubernetes_test.go b/discovery/kubernetes/kubernetes_test.go index f8edec23cb..b4bba381a4 100644 --- a/discovery/kubernetes/kubernetes_test.go +++ b/discovery/kubernetes/kubernetes_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,6 +17,7 @@ import ( "context" "encoding/json" "errors" + "os" "testing" "time" @@ -42,6 +43,14 @@ import ( ) func TestMain(m *testing.M) { + // Disable the WatchListClient feature gate that is enabled by default in + // client-go v0.35.0+. The WatchList flow requires the server to support + // SendInitialEvents and to send a bookmark event with the + // "k8s.io/initial-events-end" annotation. The fake clientset used in tests + // does not support this protocol, causing informers to hang indefinitely + // waiting for the bookmark. Disabling this feature restores the traditional + // List+Watch flow which is compatible with the fake clientset. + os.Setenv("KUBE_FEATURE_WatchListClient", "false") testutil.TolerantVerifyLeak(m) } @@ -52,7 +61,7 @@ func makeDiscovery(role Role, nsDiscovery NamespaceDiscovery, objects ...runtime // makeDiscoveryWithVersion creates a kubernetes.Discovery instance with the specified kubernetes version for testing. func makeDiscoveryWithVersion(role Role, nsDiscovery NamespaceDiscovery, k8sVer string, objects ...runtime.Object) (*Discovery, kubernetes.Interface) { - clientset := fake.NewSimpleClientset(objects...) + clientset := fake.NewClientset(objects...) fakeDiscovery, _ := clientset.Discovery().(*fakediscovery.FakeDiscovery) fakeDiscovery.FakedServerVersion = &version.Info{GitVersion: k8sVer} diff --git a/discovery/kubernetes/metrics.go b/discovery/kubernetes/metrics.go index ba3cb1d32a..cdf158a032 100644 --- a/discovery/kubernetes/metrics.go +++ b/discovery/kubernetes/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/node.go b/discovery/kubernetes/node.go index 131cdcc9e7..cbc69dd0ca 100644 --- a/discovery/kubernetes/node.go +++ b/discovery/kubernetes/node.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/node_test.go b/discovery/kubernetes/node_test.go index bc17efdc01..9e56b95bb9 100644 --- a/discovery/kubernetes/node_test.go +++ b/discovery/kubernetes/node_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/pod.go b/discovery/kubernetes/pod.go index 03089e39d4..1fed78b3a7 100644 --- a/discovery/kubernetes/pod.go +++ b/discovery/kubernetes/pod.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/pod_test.go b/discovery/kubernetes/pod_test.go index 2cf336774a..db5db546d0 100644 --- a/discovery/kubernetes/pod_test.go +++ b/discovery/kubernetes/pod_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/service.go b/discovery/kubernetes/service.go index d676490d6c..ac2d42fc7c 100644 --- a/discovery/kubernetes/service.go +++ b/discovery/kubernetes/service.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/kubernetes/service_test.go b/discovery/kubernetes/service_test.go index 43c2b7922d..56a785d9c2 100644 --- a/discovery/kubernetes/service_test.go +++ b/discovery/kubernetes/service_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/linode/linode.go b/discovery/linode/linode.go index fe61e122e4..a5f05600c1 100644 --- a/discovery/linode/linode.go +++ b/discovery/linode/linode.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net" "net/http" "strconv" @@ -103,7 +102,7 @@ func (*SDConfig) Name() string { return "linode" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -138,8 +137,8 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*linodeMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*linodeMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -170,8 +169,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "linode", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/linode/linode_test.go b/discovery/linode/linode_test.go index 7bcaa05ba4..d795d29698 100644 --- a/discovery/linode/linode_test.go +++ b/discovery/linode/linode_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -238,7 +238,11 @@ func TestLinodeSDRefresh(t *testing.T) { defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "linode", + }) require.NoError(t, err) endpoint, err := url.Parse(sdmock.Endpoint()) require.NoError(t, err) diff --git a/discovery/linode/metrics.go b/discovery/linode/metrics.go index 8f81389226..5bc805a60e 100644 --- a/discovery/linode/metrics.go +++ b/discovery/linode/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/linode/mock_test.go b/discovery/linode/mock_test.go index 50f0572ecd..b8094ec211 100644 --- a/discovery/linode/mock_test.go +++ b/discovery/linode/mock_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/manager.go b/discovery/manager.go index 6688152da9..3f2b2db652 100644 --- a/discovery/manager.go +++ b/discovery/manager.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -27,6 +27,7 @@ import ( "github.com/prometheus/common/promslog" "github.com/prometheus/prometheus/discovery/targetgroup" + "github.com/prometheus/prometheus/util/features" ) type poolKey struct { @@ -111,6 +112,13 @@ func NewManager(ctx context.Context, logger *slog.Logger, registerer prometheus. } mgr.metrics = metrics + // Register all available service discovery providers with the feature registry. + if mgr.featureRegistry != nil { + for _, sdName := range RegisteredConfigNames() { + mgr.featureRegistry.Enable(features.ServiceDiscoveryProviders, sdName) + } + } + return mgr } @@ -141,6 +149,15 @@ func HTTPClientOptions(opts ...config.HTTPClientOption) func(*Manager) { } } +// FeatureRegistry sets the feature registry for the manager. +func FeatureRegistry(fr features.Collector) func(*Manager) { + return func(m *Manager) { + m.mtx.Lock() + defer m.mtx.Unlock() + m.featureRegistry = fr + } +} + // Manager maintains a set of discovery providers and sends each update to a map channel. // Targets are grouped by the target set name. type Manager struct { @@ -175,6 +192,9 @@ type Manager struct { metrics *Metrics sdMetrics map[string]DiscovererMetrics + + // featureRegistry is used to track which service discovery providers are configured. + featureRegistry features.Collector } // Providers returns the currently configured SD providers. @@ -479,6 +499,7 @@ func (m *Manager) registerProviders(cfgs Configs, setName string) int { Logger: m.logger.With("discovery", typ, "config", setName), HTTPClientOptions: m.httpOpts, Metrics: m.sdMetrics[typ], + SetName: setName, }) if err != nil { m.logger.Error("Cannot create service discovery", "err", err, "type", typ, "config", setName) diff --git a/discovery/manager_test.go b/discovery/manager_test.go index 5d34cb7ac0..8a49005100 100644 --- a/discovery/manager_test.go +++ b/discovery/manager_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -1562,11 +1562,9 @@ func TestConfigReloadAndShutdownRace(t *testing.T) { discoveryManager.updatert = 100 * time.Millisecond var wgDiscovery sync.WaitGroup - wgDiscovery.Add(1) - go func() { + wgDiscovery.Go(func() { discoveryManager.Run() - wgDiscovery.Done() - }() + }) time.Sleep(time.Millisecond * 200) var wgBg sync.WaitGroup @@ -1588,11 +1586,9 @@ func TestConfigReloadAndShutdownRace(t *testing.T) { discoveryManager.ApplyConfig(c) delete(c, "prometheus") - wgBg.Add(1) - go func() { + wgBg.Go(func() { discoveryManager.ApplyConfig(c) - wgBg.Done() - }() + }) mgrCancel() wgDiscovery.Wait() diff --git a/discovery/marathon/marathon.go b/discovery/marathon/marathon.go index cae040ca98..878d404373 100644 --- a/discovery/marathon/marathon.go +++ b/discovery/marathon/marathon.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -19,7 +19,6 @@ import ( "errors" "fmt" "io" - "log/slog" "math/rand" "net" "net/http" @@ -91,7 +90,7 @@ func (*SDConfig) Name() string { return "marathon" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(*c, opts.Logger, opts.Metrics) + return NewDiscovery(*c, opts) } // SetDirectory joins any relative file paths with dir. @@ -140,8 +139,8 @@ type Discovery struct { } // NewDiscovery returns a new Marathon Discovery. -func NewDiscovery(conf SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*marathonMetrics) +func NewDiscovery(conf SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*marathonMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -168,8 +167,9 @@ func NewDiscovery(conf SDConfig, logger *slog.Logger, metrics discovery.Discover } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "marathon", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/marathon/marathon_test.go b/discovery/marathon/marathon_test.go index 588532d218..71c7d73d7e 100644 --- a/discovery/marathon/marathon_test.go +++ b/discovery/marathon/marathon_test.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -51,7 +51,11 @@ func testUpdateServices(client appListClient) ([]*targetgroup.Group, error) { defer metrics.Unregister() defer refreshMetrics.Unregister() - md, err := NewDiscovery(cfg, nil, metrics) + md, err := NewDiscovery(cfg, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "marathon", + }) if err != nil { return nil, err } @@ -132,7 +136,11 @@ func TestMarathonSDRemoveApp(t *testing.T) { defer metrics.Unregister() defer refreshMetrics.Unregister() - md, err := NewDiscovery(cfg, nil, metrics) + md, err := NewDiscovery(cfg, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "marathon", + }) require.NoError(t, err) md.appsClient = func(context.Context, *http.Client, string) (*appList, error) { diff --git a/discovery/marathon/metrics.go b/discovery/marathon/metrics.go index 40e2ade558..3d3d57d9ae 100644 --- a/discovery/marathon/metrics.go +++ b/discovery/marathon/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/metrics.go b/discovery/metrics.go index 356be1ddcb..2a3734fb2d 100644 --- a/discovery/metrics.go +++ b/discovery/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/metrics_k8s_client.go b/discovery/metrics_k8s_client.go index 19dfd4e247..3642eac568 100644 --- a/discovery/metrics_k8s_client.go +++ b/discovery/metrics_k8s_client.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/metrics_refresh.go b/discovery/metrics_refresh.go index ef49e591a3..11092d9f96 100644 --- a/discovery/metrics_refresh.go +++ b/discovery/metrics_refresh.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -14,6 +14,8 @@ package discovery import ( + "time" + "github.com/prometheus/client_golang/prometheus" ) @@ -21,8 +23,9 @@ import ( // We define them here in the "discovery" package in order to avoid a cyclic dependency between // "discovery" and "refresh". type RefreshMetricsVecs struct { - failuresVec *prometheus.CounterVec - durationVec *prometheus.SummaryVec + failuresVec *prometheus.CounterVec + durationVec *prometheus.SummaryVec + durationHistVec *prometheus.HistogramVec metricRegisterer MetricRegisterer } @@ -36,13 +39,23 @@ func NewRefreshMetrics(reg prometheus.Registerer) RefreshMetricsManager { Name: "prometheus_sd_refresh_failures_total", Help: "Number of refresh failures for the given SD mechanism.", }, - []string{"mechanism"}), + []string{"mechanism", "config"}), durationVec: prometheus.NewSummaryVec( prometheus.SummaryOpts{ Name: "prometheus_sd_refresh_duration_seconds", Help: "The duration of a refresh in seconds for the given SD mechanism.", Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, }, + []string{"mechanism", "config"}), + durationHistVec: prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "prometheus_sd_refresh_duration_histogram_seconds", + Help: "The duration of a refresh for the given SD mechanism.", + Buckets: []float64{.01, .1, 1, 10}, + NativeHistogramBucketFactor: 1.1, + NativeHistogramMaxBucketNumber: 100, + NativeHistogramMinResetDuration: 1 * time.Hour, + }, []string{"mechanism"}), } @@ -51,16 +64,18 @@ func NewRefreshMetrics(reg prometheus.Registerer) RefreshMetricsManager { m.metricRegisterer = NewMetricRegisterer(reg, []prometheus.Collector{ m.failuresVec, m.durationVec, + m.durationHistVec, }) return m } -// Instantiate returns metrics out of metric vectors. -func (m *RefreshMetricsVecs) Instantiate(mech string) *RefreshMetrics { +// Instantiate returns metrics out of metric vectors for a given mechanism and config. +func (m *RefreshMetricsVecs) Instantiate(mech, config string) *RefreshMetrics { return &RefreshMetrics{ - Failures: m.failuresVec.WithLabelValues(mech), - Duration: m.durationVec.WithLabelValues(mech), + Failures: m.failuresVec.WithLabelValues(mech, config), + Duration: m.durationVec.WithLabelValues(mech, config), + DurationHistogram: m.durationHistVec.WithLabelValues(mech), } } diff --git a/discovery/moby/docker.go b/discovery/moby/docker.go index cb5577a131..aa1cd2eb42 100644 --- a/discovery/moby/docker.go +++ b/discovery/moby/docker.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net" "net/http" "net/url" @@ -94,7 +93,7 @@ func (*DockerSDConfig) Name() string { return "docker" } // NewDiscoverer returns a Discoverer for the Config. func (c *DockerSDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDockerDiscovery(c, opts.Logger, opts.Metrics) + return NewDockerDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -129,8 +128,8 @@ type DockerDiscovery struct { } // NewDockerDiscovery returns a new DockerDiscovery which periodically refreshes its targets. -func NewDockerDiscovery(conf *DockerSDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*DockerDiscovery, error) { - m, ok := metrics.(*dockerMetrics) +func NewDockerDiscovery(conf *DockerSDConfig, opts discovery.DiscovererOptions) (*DockerDiscovery, error) { + m, ok := opts.Metrics.(*dockerMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -146,7 +145,7 @@ func NewDockerDiscovery(conf *DockerSDConfig, logger *slog.Logger, metrics disco return nil, err } - opts := []client.Opt{ + clientOpts := []client.Opt{ client.WithHost(conf.Host), client.WithAPIVersionNegotiation(), } @@ -166,7 +165,7 @@ func NewDockerDiscovery(conf *DockerSDConfig, logger *slog.Logger, metrics disco if err != nil { return nil, err } - opts = append(opts, + clientOpts = append(clientOpts, client.WithHTTPClient(&http.Client{ Transport: rt, Timeout: time.Duration(conf.RefreshInterval), @@ -178,15 +177,16 @@ func NewDockerDiscovery(conf *DockerSDConfig, logger *slog.Logger, metrics disco ) } - d.client, err = client.NewClientWithOpts(opts...) + d.client, err = client.NewClientWithOpts(clientOpts...) if err != nil { return nil, fmt.Errorf("error setting up docker client: %w", err) } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "docker", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/moby/docker_test.go b/discovery/moby/docker_test.go index 430669c113..effdf90b36 100644 --- a/discovery/moby/docker_test.go +++ b/discovery/moby/docker_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -48,7 +48,11 @@ host: %s defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDockerDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDockerDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "docker_swarm", + }) require.NoError(t, err) ctx := context.Background() @@ -226,7 +230,11 @@ host: %s require.NoError(t, metrics.Register()) defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDockerDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDockerDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "docker_swarm", + }) require.NoError(t, err) ctx := context.Background() diff --git a/discovery/moby/dockerswarm.go b/discovery/moby/dockerswarm.go index 44abb0ab25..5cb12279d8 100644 --- a/discovery/moby/dockerswarm.go +++ b/discovery/moby/dockerswarm.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net/http" "net/url" "time" @@ -81,7 +80,7 @@ func (*DockerSwarmSDConfig) Name() string { return "dockerswarm" } // NewDiscoverer returns a Discoverer for the Config. func (c *DockerSwarmSDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -124,8 +123,8 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf *DockerSwarmSDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*dockerswarmMetrics) +func NewDiscovery(conf *DockerSwarmSDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*dockerswarmMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -140,7 +139,7 @@ func NewDiscovery(conf *DockerSwarmSDConfig, logger *slog.Logger, metrics discov return nil, err } - opts := []client.Opt{ + clientOpts := []client.Opt{ client.WithHost(conf.Host), client.WithAPIVersionNegotiation(), } @@ -160,7 +159,7 @@ func NewDiscovery(conf *DockerSwarmSDConfig, logger *slog.Logger, metrics discov if err != nil { return nil, err } - opts = append(opts, + clientOpts = append(clientOpts, client.WithHTTPClient(&http.Client{ Transport: rt, Timeout: time.Duration(conf.RefreshInterval), @@ -172,15 +171,16 @@ func NewDiscovery(conf *DockerSwarmSDConfig, logger *slog.Logger, metrics discov ) } - d.client, err = client.NewClientWithOpts(opts...) + d.client, err = client.NewClientWithOpts(clientOpts...) if err != nil { return nil, fmt.Errorf("error setting up docker swarm client: %w", err) } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "dockerswarm", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/moby/metrics_docker.go b/discovery/moby/metrics_docker.go index 716f52b60a..8c2518a75e 100644 --- a/discovery/moby/metrics_docker.go +++ b/discovery/moby/metrics_docker.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/moby/metrics_dockerswarm.go b/discovery/moby/metrics_dockerswarm.go index 17dd30d1b3..e4682b032a 100644 --- a/discovery/moby/metrics_dockerswarm.go +++ b/discovery/moby/metrics_dockerswarm.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/moby/mock_test.go b/discovery/moby/mock_test.go index 2450ca4436..e43319494d 100644 --- a/discovery/moby/mock_test.go +++ b/discovery/moby/mock_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/moby/network.go b/discovery/moby/network.go index ea1ca66bc7..02db2b8a12 100644 --- a/discovery/moby/network.go +++ b/discovery/moby/network.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/moby/nodes.go b/discovery/moby/nodes.go index a11afeee25..76e090c803 100644 --- a/discovery/moby/nodes.go +++ b/discovery/moby/nodes.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/moby/nodes_test.go b/discovery/moby/nodes_test.go index 35676a3a8d..1f97016297 100644 --- a/discovery/moby/nodes_test.go +++ b/discovery/moby/nodes_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -48,7 +48,11 @@ host: %s defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "docker_swarm", + }) require.NoError(t, err) ctx := context.Background() diff --git a/discovery/moby/services.go b/discovery/moby/services.go index 0698c01e6a..558d544e25 100644 --- a/discovery/moby/services.go +++ b/discovery/moby/services.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/moby/services_test.go b/discovery/moby/services_test.go index af6ce842d1..eb5c75c71e 100644 --- a/discovery/moby/services_test.go +++ b/discovery/moby/services_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -48,7 +48,11 @@ host: %s defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "moby", + }) require.NoError(t, err) ctx := context.Background() @@ -349,7 +353,11 @@ filters: defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "moby", + }) require.NoError(t, err) ctx := context.Background() diff --git a/discovery/moby/tasks.go b/discovery/moby/tasks.go index 8a3dbe8101..d4e3678ee5 100644 --- a/discovery/moby/tasks.go +++ b/discovery/moby/tasks.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/moby/tasks_test.go b/discovery/moby/tasks_test.go index afb19abbee..60453990c4 100644 --- a/discovery/moby/tasks_test.go +++ b/discovery/moby/tasks_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -48,7 +48,11 @@ host: %s defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "docker_swarm", + }) require.NoError(t, err) ctx := context.Background() diff --git a/discovery/nomad/metrics.go b/discovery/nomad/metrics.go index 9707153d91..0e5dca4723 100644 --- a/discovery/nomad/metrics.go +++ b/discovery/nomad/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/nomad/nomad.go b/discovery/nomad/nomad.go index e204b740f7..da558f54d9 100644 --- a/discovery/nomad/nomad.go +++ b/discovery/nomad/nomad.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net" "strconv" "strings" @@ -84,7 +83,7 @@ func (*SDConfig) Name() string { return "nomad" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -121,8 +120,8 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*nomadMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*nomadMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -157,8 +156,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "nomad", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/nomad/nomad_test.go b/discovery/nomad/nomad_test.go index a73b45785d..3a4963e24b 100644 --- a/discovery/nomad/nomad_test.go +++ b/discovery/nomad/nomad_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -150,7 +150,11 @@ func TestConfiguredService(t *testing.T) { require.NoError(t, metrics.Register()) defer metrics.Unregister() - _, err := NewDiscovery(conf, nil, metrics) + _, err := NewDiscovery(conf, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "nomad", + }) if tc.acceptedURL { require.NoError(t, err) } else { @@ -178,7 +182,11 @@ func TestNomadSDRefresh(t *testing.T) { defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "nomad", + }) require.NoError(t, err) tgs, err := d.refresh(context.Background()) diff --git a/discovery/openstack/hypervisor.go b/discovery/openstack/hypervisor.go index e7a6362052..141b77c706 100644 --- a/discovery/openstack/hypervisor.go +++ b/discovery/openstack/hypervisor.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/hypervisor_test.go b/discovery/openstack/hypervisor_test.go index e4a97f32cf..afba84af2d 100644 --- a/discovery/openstack/hypervisor_test.go +++ b/discovery/openstack/hypervisor_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/instance.go b/discovery/openstack/instance.go index 58bf154555..2a6a777e9a 100644 --- a/discovery/openstack/instance.go +++ b/discovery/openstack/instance.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/instance_test.go b/discovery/openstack/instance_test.go index 0933b57067..aa202cddff 100644 --- a/discovery/openstack/instance_test.go +++ b/discovery/openstack/instance_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/loadbalancer.go b/discovery/openstack/loadbalancer.go index 254b713cdd..3b2def0d6a 100644 --- a/discovery/openstack/loadbalancer.go +++ b/discovery/openstack/loadbalancer.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/loadbalancer_test.go b/discovery/openstack/loadbalancer_test.go index eee21b9831..68be323a5a 100644 --- a/discovery/openstack/loadbalancer_test.go +++ b/discovery/openstack/loadbalancer_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/metrics.go b/discovery/openstack/metrics.go index 664f5ea6bc..01e7ab3add 100644 --- a/discovery/openstack/metrics.go +++ b/discovery/openstack/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/mock_test.go b/discovery/openstack/mock_test.go index 34e09c710f..c44dadfbc0 100644 --- a/discovery/openstack/mock_test.go +++ b/discovery/openstack/mock_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/openstack/openstack.go b/discovery/openstack/openstack.go index 7f23757297..ce365e6cd0 100644 --- a/discovery/openstack/openstack.go +++ b/discovery/openstack/openstack.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -78,7 +78,7 @@ func (*SDConfig) Name() string { return "openstack" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -145,20 +145,21 @@ type refresher interface { } // NewDiscovery returns a new OpenStack Discoverer which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, l *slog.Logger, metrics discovery.DiscovererMetrics) (*refresh.Discovery, error) { - m, ok := metrics.(*openstackMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*refresh.Discovery, error) { + m, ok := opts.Metrics.(*openstackMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - r, err := newRefresher(conf, l) + r, err := newRefresher(conf, opts.Logger) if err != nil { return nil, err } return refresh.NewDiscovery( refresh.Options{ - Logger: l, + Logger: opts.Logger, Mech: "openstack", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: r.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/ovhcloud/dedicated_server.go b/discovery/ovhcloud/dedicated_server.go index 2035e92c91..e892607c34 100644 --- a/discovery/ovhcloud/dedicated_server.go +++ b/discovery/ovhcloud/dedicated_server.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ovhcloud/dedicated_server_test.go b/discovery/ovhcloud/dedicated_server_test.go index 686fa7ef3f..84fa2c4c12 100644 --- a/discovery/ovhcloud/dedicated_server_test.go +++ b/discovery/ovhcloud/dedicated_server_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ovhcloud/metrics.go b/discovery/ovhcloud/metrics.go index 18492c0ab4..dbcfe130e9 100644 --- a/discovery/ovhcloud/metrics.go +++ b/discovery/ovhcloud/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ovhcloud/ovhcloud.go b/discovery/ovhcloud/ovhcloud.go index 69c7cd6004..863fcfeaf9 100644 --- a/discovery/ovhcloud/ovhcloud.go +++ b/discovery/ovhcloud/ovhcloud.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -100,8 +100,8 @@ func createClient(config *SDConfig) (*ovh.Client, error) { } // NewDiscoverer returns a Discoverer for the Config. -func (c *SDConfig) NewDiscoverer(options discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, options.Logger, options.Metrics) +func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewDiscovery(c, opts) } func init() { @@ -148,21 +148,22 @@ func newRefresher(conf *SDConfig, logger *slog.Logger) (refresher, error) { } // NewDiscovery returns a new OVHcloud Discoverer which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*refresh.Discovery, error) { - m, ok := metrics.(*ovhcloudMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*refresh.Discovery, error) { + m, ok := opts.Metrics.(*ovhcloudMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - r, err := newRefresher(conf, logger) + r, err := newRefresher(conf, opts.Logger) if err != nil { return nil, err } return refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "ovhcloud", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: r.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/ovhcloud/ovhcloud_test.go b/discovery/ovhcloud/ovhcloud_test.go index 8f2272b746..acb1c43fad 100644 --- a/discovery/ovhcloud/ovhcloud_test.go +++ b/discovery/ovhcloud/ovhcloud_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ovhcloud/vps.go b/discovery/ovhcloud/vps.go index 4e71a877bc..4023c4ff49 100644 --- a/discovery/ovhcloud/vps.go +++ b/discovery/ovhcloud/vps.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/ovhcloud/vps_test.go b/discovery/ovhcloud/vps_test.go index 051d52e85e..d997f2bb0e 100644 --- a/discovery/ovhcloud/vps_test.go +++ b/discovery/ovhcloud/vps_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/puppetdb/metrics.go b/discovery/puppetdb/metrics.go index 83e7975ed5..5a8e9736c2 100644 --- a/discovery/puppetdb/metrics.go +++ b/discovery/puppetdb/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/puppetdb/puppetdb.go b/discovery/puppetdb/puppetdb.go index a5163addb0..52a1cf73c6 100644 --- a/discovery/puppetdb/puppetdb.go +++ b/discovery/puppetdb/puppetdb.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -20,7 +20,6 @@ import ( "errors" "fmt" "io" - "log/slog" "net" "net/http" "net/url" @@ -93,7 +92,7 @@ func (*SDConfig) Name() string { return "puppetdb" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -140,14 +139,14 @@ type Discovery struct { } // NewDiscovery returns a new PuppetDB discovery for the given config. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*puppetdbMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*puppetdbMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - if logger == nil { - logger = promslog.NewNopLogger() + if opts.Logger == nil { + opts.Logger = promslog.NewNopLogger() } client, err := config.NewClientFromConfig(conf.HTTPClientConfig, "http") @@ -172,8 +171,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "puppetdb", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/puppetdb/puppetdb_test.go b/discovery/puppetdb/puppetdb_test.go index 57e198e131..b12835b47c 100644 --- a/discovery/puppetdb/puppetdb_test.go +++ b/discovery/puppetdb/puppetdb_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -70,7 +70,11 @@ func TestPuppetSlashInURL(t *testing.T) { metrics := cfg.NewDiscovererMetrics(reg, refreshMetrics) require.NoError(t, metrics.Register()) - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "puppetdb", + }) require.NoError(t, err) require.Equal(t, apiURL, d.url) @@ -94,7 +98,11 @@ func TestPuppetDBRefresh(t *testing.T) { metrics := cfg.NewDiscovererMetrics(reg, refreshMetrics) require.NoError(t, metrics.Register()) - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "puppetdb", + }) require.NoError(t, err) ctx := context.Background() @@ -142,7 +150,11 @@ func TestPuppetDBRefreshWithParameters(t *testing.T) { metrics := cfg.NewDiscovererMetrics(reg, refreshMetrics) require.NoError(t, metrics.Register()) - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "puppetdb", + }) require.NoError(t, err) ctx := context.Background() @@ -201,7 +213,11 @@ func TestPuppetDBInvalidCode(t *testing.T) { metrics := cfg.NewDiscovererMetrics(reg, refreshMetrics) require.NoError(t, metrics.Register()) - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "puppetdb", + }) require.NoError(t, err) ctx := context.Background() @@ -229,7 +245,11 @@ func TestPuppetDBInvalidFormat(t *testing.T) { metrics := cfg.NewDiscovererMetrics(reg, refreshMetrics) require.NoError(t, metrics.Register()) - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "puppetdb", + }) require.NoError(t, err) ctx := context.Background() diff --git a/discovery/puppetdb/resources.go b/discovery/puppetdb/resources.go index 487c471c1b..09aa43a776 100644 --- a/discovery/puppetdb/resources.go +++ b/discovery/puppetdb/resources.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/refresh/refresh.go b/discovery/refresh/refresh.go index 31646c0e4c..3e766d1c84 100644 --- a/discovery/refresh/refresh.go +++ b/discovery/refresh/refresh.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -28,6 +28,7 @@ import ( type Options struct { Logger *slog.Logger Mech string + SetName string Interval time.Duration RefreshF func(ctx context.Context) ([]*targetgroup.Group, error) MetricsInstantiator discovery.RefreshMetricsInstantiator @@ -43,7 +44,7 @@ type Discovery struct { // NewDiscovery returns a Discoverer function that calls a refresh() function at every interval. func NewDiscovery(opts Options) *Discovery { - m := opts.MetricsInstantiator.Instantiate(opts.Mech) + m := opts.MetricsInstantiator.Instantiate(opts.Mech, opts.SetName) var logger *slog.Logger if opts.Logger == nil { @@ -107,6 +108,7 @@ func (d *Discovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { now := time.Now() defer func() { d.metrics.Duration.Observe(time.Since(now).Seconds()) + d.metrics.DurationHistogram.Observe(time.Since(now).Seconds()) }() tgs, err := d.refreshf(ctx) diff --git a/discovery/refresh/refresh_test.go b/discovery/refresh/refresh_test.go index a6213db6c8..e227d0abc9 100644 --- a/discovery/refresh/refresh_test.go +++ b/discovery/refresh/refresh_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -76,6 +76,7 @@ func TestRefresh(t *testing.T) { Options{ Logger: nil, Mech: "test", + SetName: "test-refresh", Interval: interval, RefreshF: refresh, MetricsInstantiator: metrics, diff --git a/discovery/registry.go b/discovery/registry.go index 33938cef3e..04145e72e4 100644 --- a/discovery/registry.go +++ b/discovery/registry.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -280,3 +280,13 @@ func RegisterSDMetrics(registerer prometheus.Registerer, rmm RefreshMetricsManag } return metrics, nil } + +// RegisteredConfigNames returns the names of all registered service discovery providers. +func RegisteredConfigNames() []string { + names := make([]string, 0, len(configNames)) + for name := range configNames { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/discovery/scaleway/baremetal.go b/discovery/scaleway/baremetal.go index 06f13532df..347ed40bab 100644 --- a/discovery/scaleway/baremetal.go +++ b/discovery/scaleway/baremetal.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/scaleway/instance.go b/discovery/scaleway/instance.go index 162a75e407..c0ed5853b3 100644 --- a/discovery/scaleway/instance.go +++ b/discovery/scaleway/instance.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/scaleway/instance_test.go b/discovery/scaleway/instance_test.go index b67b858ae0..2d0f7a67ff 100644 --- a/discovery/scaleway/instance_test.go +++ b/discovery/scaleway/instance_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/scaleway/metrics.go b/discovery/scaleway/metrics.go index d7a4e78556..5871f7e31b 100644 --- a/discovery/scaleway/metrics.go +++ b/discovery/scaleway/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/scaleway/scaleway.go b/discovery/scaleway/scaleway.go index d617e01905..f8ef6c706c 100644 --- a/discovery/scaleway/scaleway.go +++ b/discovery/scaleway/scaleway.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -17,7 +17,6 @@ import ( "context" "errors" "fmt" - "log/slog" "net/http" "os" "strings" @@ -167,8 +166,8 @@ func (c *SDConfig) UnmarshalYAML(unmarshal func(any) error) error { return c.HTTPClientConfig.Validate() } -func (c SDConfig) NewDiscoverer(options discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(&c, options.Logger, options.Metrics) +func (c SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewDiscovery(&c, opts) } // SetDirectory joins any relative file paths with dir. @@ -185,8 +184,8 @@ func init() { // the Discoverer interface. type Discovery struct{} -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*refresh.Discovery, error) { - m, ok := metrics.(*scalewayMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*refresh.Discovery, error) { + m, ok := opts.Metrics.(*scalewayMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -198,8 +197,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove return refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "scaleway", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: r.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/stackit/metrics.go b/discovery/stackit/metrics.go index 5ba565eb9c..a44d0728e3 100644 --- a/discovery/stackit/metrics.go +++ b/discovery/stackit/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/stackit/mock_test.go b/discovery/stackit/mock_test.go index 59641ce2bc..d1366508a3 100644 --- a/discovery/stackit/mock_test.go +++ b/discovery/stackit/mock_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/stackit/server.go b/discovery/stackit/server.go index 1be834a689..c553d9b3f3 100644 --- a/discovery/stackit/server.go +++ b/discovery/stackit/server.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/stackit/server_test.go b/discovery/stackit/server_test.go index 117fbdd66d..afb9460851 100644 --- a/discovery/stackit/server_test.go +++ b/discovery/stackit/server_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/stackit/stackit.go b/discovery/stackit/stackit.go index 351526e016..bae76c8897 100644 --- a/discovery/stackit/stackit.go +++ b/discovery/stackit/stackit.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -87,7 +87,7 @@ func (*SDConfig) Name() string { return "stackit" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } type refresher interface { @@ -126,21 +126,22 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*refresh.Discovery, error) { - m, ok := metrics.(*stackitMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*refresh.Discovery, error) { + m, ok := opts.Metrics.(*stackitMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } - r, err := newRefresher(conf, logger) + r, err := newRefresher(conf, opts.Logger) if err != nil { return nil, err } return refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "stackit", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: r.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/stackit/types.go b/discovery/stackit/types.go index 84b7d0266c..575acbbe56 100644 --- a/discovery/stackit/types.go +++ b/discovery/stackit/types.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/targetgroup/targetgroup.go b/discovery/targetgroup/targetgroup.go index 5c3b67d6e8..4b1670ae1b 100644 --- a/discovery/targetgroup/targetgroup.go +++ b/discovery/targetgroup/targetgroup.go @@ -1,4 +1,4 @@ -// Copyright 2013 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/targetgroup/targetgroup_test.go b/discovery/targetgroup/targetgroup_test.go index d68e29644a..1c1583d33d 100644 --- a/discovery/targetgroup/targetgroup_test.go +++ b/discovery/targetgroup/targetgroup_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/triton/metrics.go b/discovery/triton/metrics.go index ea98eae452..2d4193ee1f 100644 --- a/discovery/triton/metrics.go +++ b/discovery/triton/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/triton/triton.go b/discovery/triton/triton.go index 9300753015..b21beef9d0 100644 --- a/discovery/triton/triton.go +++ b/discovery/triton/triton.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -19,7 +19,6 @@ import ( "errors" "fmt" "io" - "log/slog" "net/http" "net/url" "strings" @@ -82,7 +81,7 @@ func (*SDConfig) Name() string { return "triton" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return New(opts.Logger, c, opts.Metrics) + return New(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -146,8 +145,8 @@ type Discovery struct { } // New returns a new Discovery which periodically refreshes its targets. -func New(logger *slog.Logger, conf *SDConfig, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*tritonMetrics) +func New(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*tritonMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -173,8 +172,9 @@ func New(logger *slog.Logger, conf *SDConfig, metrics discovery.DiscovererMetric } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "triton", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/triton/triton_test.go b/discovery/triton/triton_test.go index b0dccbf898..f2b6398bc8 100644 --- a/discovery/triton/triton_test.go +++ b/discovery/triton/triton_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -83,14 +83,17 @@ var ( func newTritonDiscovery(c SDConfig) (*Discovery, discovery.DiscovererMetrics, error) { reg := prometheus.NewRegistry() refreshMetrics := discovery.NewRefreshMetrics(reg) - // TODO(ptodev): Add the ability to unregister refresh metrics. metrics := c.NewDiscovererMetrics(reg, refreshMetrics) err := metrics.Register() if err != nil { return nil, nil, err } - d, err := New(nil, &c, metrics) + d, err := New(&c, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "triton", + }) if err != nil { return nil, nil, err } diff --git a/discovery/util.go b/discovery/util.go index 4e2a088518..064a5312a7 100644 --- a/discovery/util.go +++ b/discovery/util.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/uyuni/metrics.go b/discovery/uyuni/metrics.go index 85ea9d73d2..e1a9fd4db0 100644 --- a/discovery/uyuni/metrics.go +++ b/discovery/uyuni/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/uyuni/uyuni.go b/discovery/uyuni/uyuni.go index 6419d8d365..6f29fa130c 100644 --- a/discovery/uyuni/uyuni.go +++ b/discovery/uyuni/uyuni.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -124,7 +124,7 @@ func (*SDConfig) Name() string { return "uyuni" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -213,8 +213,8 @@ func getEndpointInfoForSystems( } // NewDiscovery returns a uyuni discovery for the given configuration. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*uyuniMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*uyuniMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -238,13 +238,14 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove entitlement: conf.Entitlement, separator: conf.Separator, interval: time.Duration(conf.RefreshInterval), - logger: logger, + logger: opts.Logger, } d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "uyuni", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/uyuni/uyuni_test.go b/discovery/uyuni/uyuni_test.go index 46567587a8..71f1c5afb1 100644 --- a/discovery/uyuni/uyuni_test.go +++ b/discovery/uyuni/uyuni_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -47,7 +47,11 @@ func testUpdateServices(respHandler http.HandlerFunc) ([]*targetgroup.Group, err defer metrics.Unregister() defer refreshMetrics.Unregister() - md, err := NewDiscovery(&conf, nil, metrics) + md, err := NewDiscovery(&conf, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "uyuni", + }) if err != nil { return nil, err } @@ -127,7 +131,11 @@ func TestUyuniSDSkipLogin(t *testing.T) { defer metrics.Unregister() defer refreshMetrics.Unregister() - md, err := NewDiscovery(&conf, nil, metrics) + md, err := NewDiscovery(&conf, discovery.DiscovererOptions{ + Logger: nil, + Metrics: metrics, + SetName: "uyuni", + }) if err != nil { t.Error(err) } diff --git a/discovery/vultr/metrics.go b/discovery/vultr/metrics.go index 65b15eae2f..823fe4bdc0 100644 --- a/discovery/vultr/metrics.go +++ b/discovery/vultr/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/vultr/mock_test.go b/discovery/vultr/mock_test.go index bfc24d06fb..03e5952dd0 100644 --- a/discovery/vultr/mock_test.go +++ b/discovery/vultr/mock_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/vultr/vultr.go b/discovery/vultr/vultr.go index 79a7a0179f..b2f6bde52a 100644 --- a/discovery/vultr/vultr.go +++ b/discovery/vultr/vultr.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -16,7 +16,6 @@ package vultr import ( "context" "errors" - "log/slog" "net" "net/http" "strconv" @@ -86,7 +85,7 @@ func (*SDConfig) Name() string { return "vultr" } // NewDiscoverer returns a Discoverer for the Config. func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { - return NewDiscovery(c, opts.Logger, opts.Metrics) + return NewDiscovery(c, opts) } // SetDirectory joins any relative file paths with dir. @@ -114,8 +113,8 @@ type Discovery struct { } // NewDiscovery returns a new Discovery which periodically refreshes its targets. -func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) { - m, ok := metrics.(*vultrMetrics) +func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) { + m, ok := opts.Metrics.(*vultrMetrics) if !ok { return nil, errors.New("invalid discovery metrics type") } @@ -138,8 +137,9 @@ func NewDiscovery(conf *SDConfig, logger *slog.Logger, metrics discovery.Discove d.Discovery = refresh.NewDiscovery( refresh.Options{ - Logger: logger, + Logger: opts.Logger, Mech: "vultr", + SetName: opts.SetName, Interval: time.Duration(conf.RefreshInterval), RefreshF: d.refresh, MetricsInstantiator: m.refreshMetrics, diff --git a/discovery/vultr/vultr_test.go b/discovery/vultr/vultr_test.go index 00ef21e38c..d116c419b7 100644 --- a/discovery/vultr/vultr_test.go +++ b/discovery/vultr/vultr_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -57,7 +57,11 @@ func TestVultrSDRefresh(t *testing.T) { defer metrics.Unregister() defer refreshMetrics.Unregister() - d, err := NewDiscovery(&cfg, promslog.NewNopLogger(), metrics) + d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{ + Logger: promslog.NewNopLogger(), + Metrics: metrics, + SetName: "vultr", + }) require.NoError(t, err) endpoint, err := url.Parse(sdMock.Mock.Endpoint()) require.NoError(t, err) diff --git a/discovery/xds/client.go b/discovery/xds/client.go index a27e060fbd..59485ffcba 100644 --- a/discovery/xds/client.go +++ b/discovery/xds/client.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/xds/client_test.go b/discovery/xds/client_test.go index 7e3cd85b6c..e663902161 100644 --- a/discovery/xds/client_test.go +++ b/discovery/xds/client_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/xds/kuma.go b/discovery/xds/kuma.go index 82ca8f2c9a..34bebe7765 100644 --- a/discovery/xds/kuma.go +++ b/discovery/xds/kuma.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/xds/kuma_mads.pb.go b/discovery/xds/kuma_mads.pb.go index 210a5343a4..d234241453 100644 --- a/discovery/xds/kuma_mads.pb.go +++ b/discovery/xds/kuma_mads.pb.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/xds/kuma_test.go b/discovery/xds/kuma_test.go index 533a31dcf3..6620f9fac6 100644 --- a/discovery/xds/kuma_test.go +++ b/discovery/xds/kuma_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -111,7 +111,6 @@ func getKumaMadsV1DiscoveryResponse(resources ...*MonitoringAssignment) (*v3.Dis func newKumaTestHTTPDiscovery(c KumaSDConfig) (*fetchDiscovery, error) { reg := prometheus.NewRegistry() refreshMetrics := discovery.NewRefreshMetrics(reg) - // TODO(ptodev): Add the ability to unregister refresh metrics. metrics := c.NewDiscovererMetrics(reg, refreshMetrics) err := metrics.Register() if err != nil { diff --git a/discovery/xds/metrics.go b/discovery/xds/metrics.go index bdc9598f2c..7e5be89bd3 100644 --- a/discovery/xds/metrics.go +++ b/discovery/xds/metrics.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/xds/xds.go b/discovery/xds/xds.go index db55a2b6f7..29da7b7c89 100644 --- a/discovery/xds/xds.go +++ b/discovery/xds/xds.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/xds/xds_test.go b/discovery/xds/xds_test.go index 5a2e9d737b..c11cdd2c05 100644 --- a/discovery/xds/xds_test.go +++ b/discovery/xds/xds_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/zookeeper/zookeeper.go b/discovery/zookeeper/zookeeper.go index d5239324cb..6ac9b25cd6 100644 --- a/discovery/zookeeper/zookeeper.go +++ b/discovery/zookeeper/zookeeper.go @@ -1,4 +1,4 @@ -// Copyright 2015 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/discovery/zookeeper/zookeeper_test.go b/discovery/zookeeper/zookeeper_test.go index de0d1f4924..ae2d23e607 100644 --- a/discovery/zookeeper/zookeeper_test.go +++ b/discovery/zookeeper/zookeeper_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/docs/command-line/prometheus.md b/docs/command-line/prometheus.md index 0396f90bee..251fdfd6a4 100644 --- a/docs/command-line/prometheus.md +++ b/docs/command-line/prometheus.md @@ -38,6 +38,7 @@ The Prometheus monitoring server | --storage.tsdb.retention.size | [DEPRECATED] Maximum number of bytes that can be stored for blocks. A unit is required, supported units: B, KB, MB, GB, TB, PB, EB. Ex: "512MB". Based on powers-of-2, so 1KB is 1024B. This flag has been deprecated, use the storage.tsdb.retention.size field in the config file instead. Use with server mode only. | | | --storage.tsdb.no-lockfile | Do not create lockfile in data directory. Use with server mode only. | `false` | | --storage.tsdb.head-chunks-write-queue-size | Size of the queue through which head chunks are written to the disk to be m-mapped, 0 disables the queue completely. Experimental. Use with server mode only. | `0` | +| --storage.tsdb.delay-compact-file.path | Path to a JSON file with uploaded TSDB blocks e.g. Thanos shipper meta file. If set TSDB will only compact 1 level blocks that are marked as uploaded in that file, improving external storage integrations e.g. with Thanos sidecar. 1+ level compactions won't be delayed. Use with server mode only. | | | --storage.agent.path | Base path for metrics storage. Use with agent mode only. | `data-agent/` | | --storage.agent.wal-compression | Compress the agent WAL. If false, the --storage.agent.wal-compression-type flag is ignored. Use with agent mode only. | `true` | | --storage.agent.retention.min-time | Minimum age samples may be before being considered for deletion when the WAL is truncated Use with agent mode only. | | @@ -58,7 +59,7 @@ The Prometheus monitoring server | --query.timeout | Maximum time a query may take before being aborted. Use with server mode only. | `2m` | | --query.max-concurrency | Maximum number of queries executed concurrently. Use with server mode only. | `20` | | --query.max-samples | Maximum number of samples a single query can load into memory. Note that queries will fail if they try to load more samples than this into memory, so this also limits the number of samples a query can return. Use with server mode only. | `50000000` | -| --enable-feature ... | Comma separated feature names to enable. Valid options: exemplar-storage, expand-external-labels, memory-snapshot-on-shutdown, promql-per-step-stats, promql-experimental-functions, extra-scrape-metrics, auto-gomaxprocs, native-histograms, created-timestamp-zero-ingestion, concurrent-rule-eval, delayed-compaction, old-ui, otlp-deltatocumulative, promql-duration-expr, use-uncached-io, promql-extended-range-selectors. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details. | | +| --enable-feature ... | Comma separated feature names to enable. Valid options: exemplar-storage, expand-external-labels, memory-snapshot-on-shutdown, promql-per-step-stats, promql-experimental-functions, extra-scrape-metrics, auto-gomaxprocs, created-timestamp-zero-ingestion, concurrent-rule-eval, delayed-compaction, old-ui, otlp-deltatocumulative, promql-duration-expr, use-uncached-io, promql-extended-range-selectors, promql-binop-fill-modifiers. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details. | | | --agent | Run Prometheus in 'Agent mode'. | | | --log.level | Only log messages with the given severity or above. One of: [debug, info, warn, error] | `info` | | --log.format | Output format of log messages. One of: [logfmt, json] | `logfmt` | diff --git a/docs/command-line/promtool.md b/docs/command-line/promtool.md index 69fcd265f2..e8ffa75aaa 100644 --- a/docs/command-line/promtool.md +++ b/docs/command-line/promtool.md @@ -12,7 +12,7 @@ Tooling for the Prometheus monitoring system. | -h, --help | Show context-sensitive help (also try --help-long and --help-man). | | --version | Show application version. | | --experimental | Enable experimental commands. | -| --enable-feature ... | Comma separated feature names to enable. Valid options: promql-experimental-functions, promql-delayed-name-removal. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details | +| --enable-feature ... | Comma separated feature names to enable. Valid options: promql-experimental-functions, promql-delayed-name-removal, promql-duration-expr, promql-extended-range-selectors. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details | @@ -59,7 +59,6 @@ Check the resources for validity. | Flag | Description | Default | | --- | --- | --- | | --query.lookback-delta | The server's maximum query lookback duration. | `5m` | -| --extended | Print extended information related to the cardinality of the metrics. | | @@ -192,13 +191,25 @@ Check if the rule files are valid or not. ##### `promtool check metrics` -Pass Prometheus metrics over stdin to lint them for consistency and correctness. +Pass Prometheus metrics over stdin to lint them for consistency and correctness, and optionally perform cardinality analysis. examples: $ cat metrics.prom | promtool check metrics -$ curl -s http://localhost:9090/metrics | promtool check metrics +$ curl -s http://localhost:9090/metrics | promtool check metrics `--extended` + +$ curl -s http://localhost:9100/metrics | promtool check metrics `--extended` `--lint`=none + + + +###### Flags + +| Flag | Description | Default | +| --- | --- | --- | +| --extended | Print extended information related to the cardinality of the metrics. | | +| --lint | Linting checks to apply for metrics. Available options are: all, none. Use --lint=none to disable metrics linting. | `all` | + @@ -570,7 +581,7 @@ List tsdb blocks. ##### `promtool tsdb dump` -Dump samples from a TSDB. +Dump data (series+samples or optionally just series) from a TSDB. @@ -582,6 +593,7 @@ Dump samples from a TSDB. | --min-time | Minimum timestamp to dump, in milliseconds since the Unix epoch. | `-9223372036854775808` | | --max-time | Maximum timestamp to dump, in milliseconds since the Unix epoch. | `9223372036854775807` | | --match ... | Series selector. Can be specified multiple times. | `{__name__=~'(?s:.*)'}` | +| --format | Output format of the dump (prom (default) or seriesjson). | `prom` | diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 7eab633987..ff4eb4c2cf 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -159,6 +159,12 @@ global: # native histogram with custom buckets. [ always_scrape_classic_histograms: | default = false ] + # When enabled, Prometheus stores additional time series for each scrape: + # scrape_timeout_seconds, scrape_sample_limit, and scrape_body_size_bytes. + # These metrics help monitor how close targets are to their configured limits. + # This option can be overridden per scrape config. + [ extra_scrape_metrics: | default = false ] + # The following explains the various combinations of the last three options # in various exposition cases. # @@ -395,6 +401,10 @@ params: # authorization), proxy configurations, TLS options, custom HTTP headers, etc. [ ] +# List of AWS service discovery configurations. +aws_sd_configs: + [ - ... ] + # List of Azure service discovery configurations. azure_sd_configs: [ - ... ] @@ -643,6 +653,12 @@ metric_relabel_configs: # native histogram with custom buckets. [ always_scrape_classic_histograms: | default = ] +# When enabled, Prometheus stores additional time series for this scrape job: +# scrape_timeout_seconds, scrape_sample_limit, and scrape_body_size_bytes. +# These metrics help monitor how close targets are to their configured limits. +# If not set, inherits the value from the global configuration. +[ extra_scrape_metrics: | default = ] + # See global configuration above for further explanations of how the last three # options combine their effects. @@ -757,16 +773,56 @@ A `tls_config` allows configuring TLS connections. OAuth 2.0 authentication using the client credentials or password grant type. Prometheus fetches an access token from the specified endpoint with -the given client access and secret keys. +the given client access and credentials. ```yaml client_id: + +# OAuth2 grant type to use. It can be one of +# "client_credentials" or "urn:ietf:params:oauth:grant-type:jwt-bearer" (RFC 7523). +# Default value is "client_credentials" +[ grant_type: ] + +# Client secret to provide to authorization server. Only used if +# GrantType is set empty or set to "client_credentials". [ client_secret: ] # Read the client secret from a file. # It is mutually exclusive with `client_secret`. [ client_secret_file: ] +# Secret key to sign JWT with. Only used if +# GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". +[ client_certificate_key: ] + +# Read the secret key from a file. +# It is mutually exclusive with `client_certificate_key`. +[ client_certificate_key_file: ] + +# JWT kid value to include in the JWT header. Only used if +# GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". +[ client_certificate_key_id: ] + +# Signature algorithm used to sign JWT token. Only used if +# GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". +# Default value is RS256 and valid values RS256, RS384, RS512 +[ signature_algorithm: ] + +# OAuth client identifier used when communicating with +# the configured OAuth provider. Default value is client_id. Only used if +# GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". +[ iss: ] + +# Intended audience of the request. If empty, the value +# of TokenURL is used as the intended audience. Only used if +# GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". +[ audience: ] + +# Map of claims to be added to the JWT token. Only used if +# GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer". +claims: + [ : ... ] + # Scopes for the token request. scopes: [ - ... ] @@ -812,6 +868,323 @@ http_headers: [ files: [, ...] ] ] ``` +### `` + +AWS SD configurations allow retrieving scrape targets from AWS services. +This is a unified service discovery that supports multiple AWS service types through the `role` parameter. + +One of the following `role` types can be configured to discover targets: + +#### `ec2` + +The `ec2` role discovers targets from AWS EC2 instances. The private IP address is used by default, but may be changed to +the public IP address with relabeling. + +The IAM credentials used must have the `ec2:DescribeInstances` permission to +discover scrape targets, and may optionally have the +`ec2:DescribeAvailabilityZones` permission if you want the availability zone ID +available as a label (see below). + +The following meta labels are available on targets during [relabeling](#relabel_config): + +* `__meta_ec2_ami`: the EC2 Amazon Machine Image +* `__meta_ec2_architecture`: the architecture of the instance +* `__meta_ec2_availability_zone`: the availability zone in which the instance is running +* `__meta_ec2_availability_zone_id`: the [availability zone ID](https://docs.aws.amazon.com/ram/latest/userguide/working-with-az-ids.html) in which the instance is running (requires `ec2:DescribeAvailabilityZones`) +* `__meta_ec2_instance_id`: the EC2 instance ID +* `__meta_ec2_instance_lifecycle`: the lifecycle of the EC2 instance, set only for 'spot' or 'scheduled' instances, absent otherwise +* `__meta_ec2_instance_state`: the state of the EC2 instance +* `__meta_ec2_instance_type`: the type of the EC2 instance +* `__meta_ec2_ipv6_addresses`: comma separated list of IPv6 addresses assigned to the instance's network interfaces, if present +* `__meta_ec2_owner_id`: the ID of the AWS account that owns the EC2 instance +* `__meta_ec2_platform`: the Operating System platform, set to 'windows' on Windows servers, absent otherwise +* `__meta_ec2_primary_ipv6_addresses`: comma separated list of the Primary IPv6 addresses of the instance, if present. The list is ordered based on the position of each corresponding network interface in the attachment order. +* `__meta_ec2_primary_subnet_id`: the subnet ID of the primary network interface, if available +* `__meta_ec2_private_dns_name`: the private DNS name of the instance, if available +* `__meta_ec2_private_ip`: the private IP address of the instance, if present +* `__meta_ec2_public_dns_name`: the public DNS name of the instance, if available +* `__meta_ec2_public_ip`: the public IP address of the instance, if available +* `__meta_ec2_region`: the region of the instance +* `__meta_ec2_subnet_id`: comma separated list of subnets IDs in which the instance is running, if available +* `__meta_ec2_tag_`: each tag value of the instance +* `__meta_ec2_vpc_id`: the ID of the VPC in which the instance is running, if available + +#### `lightsail` + +The `lightsail` role discovers targets from [AWS Lightsail](https://aws.amazon.com/lightsail/) +instances. The private IP address is used by default, but may be changed to +the public IP address with relabeling. + +The following meta labels are available on targets during [relabeling](#relabel_config): + +* `__meta_lightsail_availability_zone`: the availability zone in which the instance is running +* `__meta_lightsail_blueprint_id`: the Lightsail blueprint ID +* `__meta_lightsail_bundle_id`: the Lightsail bundle ID +* `__meta_lightsail_instance_name`: the name of the Lightsail instance +* `__meta_lightsail_instance_state`: the state of the Lightsail instance +* `__meta_lightsail_instance_support_code`: the support code of the Lightsail instance +* `__meta_lightsail_ipv6_addresses`: comma separated list of IPv6 addresses assigned to the instance's network interfaces, if present +* `__meta_lightsail_private_ip`: the private IP address of the instance +* `__meta_lightsail_public_ip`: the public IP address of the instance, if available +* `__meta_lightsail_region`: the region of the instance +* `__meta_lightsail_tag_`: each tag value of the instance + +#### `ecs` + +The `ecs` role discovers targets from AWS ECS containers. + +ECS service discovery supports all ECS networking modes: +- **awsvpc mode** (Fargate and EC2 with ENI): Uses the task's private IP address from its elastic network interface +- **bridge mode** (EC2): Uses the EC2 host instance's private IP address +- **host mode** (EC2): Uses the EC2 host instance's private IP address + +The private IP address is used by default, but may be changed to the public IP address with relabeling. + +The IAM credentials used must have the following permissions to discover scrape targets: + +- `ecs:ListClusters` +- `ecs:DescribeClusters` +- `ecs:ListServices` +- `ecs:DescribeServices` +- `ecs:ListTasks` +- `ecs:DescribeTasks` +- `ecs:DescribeContainerInstances` (required for EC2 launch type tasks) +- `ec2:DescribeInstances` (required for EC2 launch type tasks) +- `ec2:DescribeNetworkInterfaces` (required to get public IP for awsvpc mode tasks) + +The following meta labels are available on targets during [relabeling](#relabel_config): + +* `__meta_ecs_cluster`: the name of the ECS cluster +* `__meta_ecs_cluster_arn`: the ARN of the ECS cluster +* `__meta_ecs_service`: the name of the ECS service +* `__meta_ecs_service_arn`: the ARN of the ECS service +* `__meta_ecs_service_status`: the status of the ECS service +* `__meta_ecs_task_group`: the ECS task group (typically service:service-name) +* `__meta_ecs_task_arn`: the ARN of the ECS task +* `__meta_ecs_task_definition`: the ARN of the ECS task definition +* `__meta_ecs_ip_address`: the private IP address of the task +* `__meta_ecs_launch_type`: the launch type of the task (EC2 or Fargate) +* `__meta_ecs_desired_status`: the desired status of the task +* `__meta_ecs_last_status`: the last known status of the task +* `__meta_ecs_health_status`: the health status of the task +* `__meta_ecs_platform_family`: the platform family (e.g., Linux, Windows) +* `__meta_ecs_platform_version`: the platform version +* `__meta_ecs_subnet_id`: the subnet ID where the task is running +* `__meta_ecs_availability_zone`: the availability zone where the task is running +* `__meta_ecs_region`: the AWS region +* `__meta_ecs_public_ip`: the public IP address (from ENI for awsvpc mode, from EC2 instance for bridge/host mode), if available +* `__meta_ecs_network_mode`: the network mode of the task (awsvpc or bridge) +* `__meta_ecs_container_instance_arn`: the ARN of the container instance (EC2 launch type only) +* `__meta_ecs_ec2_instance_id`: the EC2 instance ID (EC2 launch type only) +* `__meta_ecs_ec2_instance_type`: the EC2 instance type (EC2 launch type only) +* `__meta_ecs_ec2_instance_private_ip`: the private IP address of the EC2 instance (EC2 launch type only) +* `__meta_ecs_ec2_instance_public_ip`: the public IP address of the EC2 instance, if available (EC2 launch type only) +* `__meta_ecs_tag_cluster_`: each cluster tag value, keyed by tag name +* `__meta_ecs_tag_service_`: each service tag value, keyed by tag name +* `__meta_ecs_tag_task_`: each task tag value, keyed by tag name +* `__meta_ecs_tag_ec2_`: each EC2 instance tag value, keyed by tag name (EC2 launch type only) + +#### `msk` + +The `msk` role discovers targets from AWS MSK (Managed Streaming for Apache Kafka) provisioned clusters. + +**Important**: This service discovery only works with **provisioned clusters**. Serverless clusters are not supported as they do not expose individual broker nodes. + +Discovery includes: +- **Broker nodes**: Kafka broker instances (supports both ZooKeeper-based and KRaft-based clusters) +- **KRaft Controller nodes**: Controller instances (KRaft-based clusters only) + +Note: ZooKeeper nodes are not discoverable via the MSK API. For monitoring, MSK provides: +- **JMX Exporter**: Available on both broker and KRaft controller nodes (when enabled) +- **Node Exporter**: Available on broker nodes only (when enabled) + +The IAM credentials used must have the following permissions to discover +scrape targets: + +- `kafka:DescribeClusterV2` +- `kafka:ListClustersV2` +- `kafka:ListNodes` + +The following meta labels are available on targets during [relabeling](#relabel_config): + +* `__meta_msk_cluster_name`: the name of the MSK cluster +* `__meta_msk_cluster_arn`: the ARN of the MSK cluster +* `__meta_msk_cluster_state`: the state of the MSK cluster (e.g., ACTIVE, CREATING, DELETING) +* `__meta_msk_cluster_type`: the type of the MSK cluster (e.g., PROVISIONED, SERVERLESS) +* `__meta_msk_cluster_version`: the current version of the MSK cluster +* `__meta_msk_cluster_kafka_version`: the Kafka version running on the cluster +* `__meta_msk_cluster_jmx_exporter_enabled`: whether JMX exporter is enabled on the cluster +* `__meta_msk_cluster_configuration_arn`: the ARN of the MSK configuration +* `__meta_msk_cluster_configuration_revision`: the revision of the MSK configuration +* `__meta_msk_cluster_tag_`: each cluster tag value, keyed by tag name +* `__meta_msk_node_type`: the type of the node (BROKER or CONTROLLER) +* `__meta_msk_node_arn`: the ARN of the node +* `__meta_msk_node_added_time`: the time the node was added to the cluster +* `__meta_msk_node_instance_type`: the instance type of the node +* `__meta_msk_node_attached_eni`: the ID of the attached ENI +* `__meta_msk_broker_id`: the broker ID (broker nodes only) +* `__meta_msk_broker_endpoint_index`: the index of the broker endpoint (broker nodes only) +* `__meta_msk_broker_client_subnet`: the client subnet of the broker (broker nodes only) +* `__meta_msk_broker_client_vpc_ip`: the VPC IP address of the broker (broker nodes only) +* `__meta_msk_broker_node_exporter_enabled`: whether node exporter is enabled on brokers (broker nodes only) +* `__meta_msk_controller_endpoint_index`: the index of the controller endpoint (controller nodes only) + +#### `elasticache` + +The `elasticache` role discovers targets from AWS ElastiCache for both serverless caches and cache clusters. + +**Important**: For cache clusters, one target is created per cache node. Each target includes the cluster-level labels (ARN, status, tags, etc.) and node-specific labels (node ID, endpoint, availability zone, etc.). The `__address__` label is set to the individual node's endpoint address and port. + +For serverless caches, one target is created per serverless cache, with the `__address__` label set to the serverless cache endpoint. + +The IAM credentials used must have the following permissions to discover scrape targets: + +- `elasticache:DescribeServerlessCaches` +- `elasticache:DescribeCacheClusters` +- `elasticache:ListTagsForResource` + +The following meta labels are available on targets during [relabeling](#relabel_config): + +**Common labels (available on all targets):** + +* `__meta_elasticache_deployment_option`: the deployment option - either `serverless` for serverless caches or `node` for cache cluster nodes + +**Serverless Cache labels:** + +* `__meta_elasticache_serverless_cache_arn`: the ARN of the serverless cache +* `__meta_elasticache_serverless_cache_name`: the name of the serverless cache +* `__meta_elasticache_serverless_cache_status`: the status of the serverless cache +* `__meta_elasticache_serverless_cache_engine`: the cache engine (redis or valkey) +* `__meta_elasticache_serverless_cache_full_engine_version`: the full engine version +* `__meta_elasticache_serverless_cache_major_engine_version`: the major engine version +* `__meta_elasticache_serverless_cache_description`: the description of the serverless cache +* `__meta_elasticache_serverless_cache_create_time`: the creation time in RFC3339 format +* `__meta_elasticache_serverless_cache_snapshot_retention_limit`: the snapshot retention limit in days +* `__meta_elasticache_serverless_cache_daily_snapshot_time`: the daily snapshot time +* `__meta_elasticache_serverless_cache_user_group_id`: the user group ID +* `__meta_elasticache_serverless_cache_kms_key_id`: the KMS key ID for encryption at rest +* `__meta_elasticache_serverless_cache_endpoint_address`: the endpoint address +* `__meta_elasticache_serverless_cache_endpoint_port`: the endpoint port +* `__meta_elasticache_serverless_cache_reader_endpoint_address`: the reader endpoint address +* `__meta_elasticache_serverless_cache_reader_endpoint_port`: the reader endpoint port +* `__meta_elasticache_serverless_cache_security_group_id_`: security group IDs (indexed) +* `__meta_elasticache_serverless_cache_subnet_id_`: subnet IDs (indexed) +* `__meta_elasticache_serverless_cache_cache_usage_limit_data_storage_maximum`: maximum data storage in the specified unit +* `__meta_elasticache_serverless_cache_cache_usage_limit_data_storage_minimum`: minimum data storage in the specified unit +* `__meta_elasticache_serverless_cache_cache_usage_limit_data_storage_unit`: unit for data storage (e.g., GB) +* `__meta_elasticache_serverless_cache_cache_usage_limit_ecpu_per_second_maximum`: maximum ECPU per second +* `__meta_elasticache_serverless_cache_cache_usage_limit_ecpu_per_second_minimum`: minimum ECPU per second +* `__meta_elasticache_serverless_cache_tag_`: each serverless cache tag value, keyed by tag name + +**Cache Cluster labels:** + +* `__meta_elasticache_cache_cluster_arn`: the ARN of the cache cluster +* `__meta_elasticache_cache_cluster_cache_cluster_id`: the cache cluster ID +* `__meta_elasticache_cache_cluster_cache_cluster_status`: the status of the cache cluster +* `__meta_elasticache_cache_cluster_engine`: the cache engine (redis or memcached) +* `__meta_elasticache_cache_cluster_engine_version`: the engine version +* `__meta_elasticache_cache_cluster_cache_node_type`: the cache node type (e.g., cache.t3.micro) +* `__meta_elasticache_cache_cluster_num_cache_nodes`: the number of cache nodes +* `__meta_elasticache_cache_cluster_cache_cluster_create_time`: the creation time in RFC3339 format +* `__meta_elasticache_cache_cluster_at_rest_encryption_enabled`: whether encryption at rest is enabled +* `__meta_elasticache_cache_cluster_transit_encryption_enabled`: whether encryption in transit is enabled +* `__meta_elasticache_cache_cluster_transit_encryption_mode`: the transit encryption mode +* `__meta_elasticache_cache_cluster_auth_token_enabled`: whether auth token is enabled +* `__meta_elasticache_cache_cluster_auth_token_last_modified`: the last modification time of auth token +* `__meta_elasticache_cache_cluster_auto_minor_version_upgrade`: whether auto minor version upgrade is enabled +* `__meta_elasticache_cache_cluster_cache_parameter_group`: the cache parameter group name +* `__meta_elasticache_cache_cluster_cache_subnet_group_name`: the cache subnet group name +* `__meta_elasticache_cache_cluster_client_download_landing_page`: the client download landing page URL +* `__meta_elasticache_cache_cluster_ip_discovery`: the IP discovery mode (ipv4 or ipv6) +* `__meta_elasticache_cache_cluster_network_type`: the network type (ipv4, ipv6, or dual_stack) +* `__meta_elasticache_cache_cluster_preferred_availability_zone`: the preferred availability zone +* `__meta_elasticache_cache_cluster_preferred_maintenance_window`: the preferred maintenance window +* `__meta_elasticache_cache_cluster_preferred_outpost_arn`: the preferred outpost ARN +* `__meta_elasticache_cache_cluster_replication_group_id`: the replication group ID (for Redis clusters that are part of a replication group) +* `__meta_elasticache_cache_cluster_replication_group_log_delivery_enabled`: whether log delivery is enabled for the replication group +* `__meta_elasticache_cache_cluster_snapshot_retention_limit`: the snapshot retention limit in days +* `__meta_elasticache_cache_cluster_snapshot_window`: the daily snapshot window +* `__meta_elasticache_cache_cluster_configuration_endpoint_address`: the configuration endpoint address (cluster mode enabled only) +* `__meta_elasticache_cache_cluster_configuration_endpoint_port`: the configuration endpoint port (cluster mode enabled only) +* `__meta_elasticache_cache_cluster_notification_topic_arn`: the SNS topic ARN for notifications +* `__meta_elasticache_cache_cluster_notification_topic_status`: the SNS topic status +* `__meta_elasticache_cache_cluster_log_delivery_configuration_destination_type_`: log delivery destination type (cloudwatch-logs or kinesis-firehose) +* `__meta_elasticache_cache_cluster_log_delivery_configuration_log_format_`: log format (text or json) +* `__meta_elasticache_cache_cluster_log_delivery_configuration_log_type_`: log type (slow-log or engine-log) +* `__meta_elasticache_cache_cluster_log_delivery_configuration_status_`: log delivery status +* `__meta_elasticache_cache_cluster_log_delivery_configuration_message_`: log delivery message +* `__meta_elasticache_cache_cluster_log_delivery_configuration_log_group_`: CloudWatch log group name (cloudwatch-logs destination only) +* `__meta_elasticache_cache_cluster_log_delivery_configuration_delivery_stream_`: Kinesis Firehose delivery stream name (kinesis-firehose destination only) +* `__meta_elasticache_cache_cluster_pending_modified_values_auth_token_status`: pending auth token status +* `__meta_elasticache_cache_cluster_pending_modified_values_cache_node_type`: pending cache node type change +* `__meta_elasticache_cache_cluster_pending_modified_values_engine_version`: pending engine version upgrade +* `__meta_elasticache_cache_cluster_pending_modified_values_num_cache_nodes`: pending number of cache nodes +* `__meta_elasticache_cache_cluster_pending_modified_values_transit_encryption_enabled`: pending transit encryption status +* `__meta_elasticache_cache_cluster_pending_modified_values_transit_encryption_mode`: pending transit encryption mode +* `__meta_elasticache_cache_cluster_pending_modified_values_cache_node_ids_to_remove`: comma-separated list of cache node IDs to be removed +* `__meta_elasticache_cache_cluster_security_group_membership_id_`: security group ID (indexed) +* `__meta_elasticache_cache_cluster_security_group_membership_status_`: security group status (indexed) +* `__meta_elasticache_cache_cluster_node_id`: cache node ID +* `__meta_elasticache_cache_cluster_node_status`: cache node status +* `__meta_elasticache_cache_cluster_node_create_time`: cache node creation time in RFC3339 format +* `__meta_elasticache_cache_cluster_node_availability_zone`: cache node availability zone +* `__meta_elasticache_cache_cluster_node_customer_outpost_arn`: cache node outpost ARN +* `__meta_elasticache_cache_cluster_node_source_cache_node_id`: source cache node ID for replication +* `__meta_elasticache_cache_cluster_node_parameter_group_status`: parameter group status +* `__meta_elasticache_cache_cluster_node_endpoint_address`: cache node endpoint address +* `__meta_elasticache_cache_cluster_node_endpoint_port`: cache node endpoint port +* `__meta_elasticache_cache_cluster_tag_`: each cache cluster tag value, keyed by tag name + +See below for the configuration options for AWS discovery: + +```yaml +# The AWS role to use for service discovery. +# Must be one of: ec2, lightsail, ecs, msk, or elasticache. +role: + +# The AWS region. If blank, the region from the instance metadata is used. +[ region: ] + +# Custom endpoint to be used. +[ endpoint: ] + +# AWS access key ID. If blank, the environment variable AWS_ACCESS_KEY_ID is used. +[ access_key: ] + +# AWS secret access key. If blank, the environment variable AWS_SECRET_ACCESS_KEY is used. +[ secret_key: ] + +# Named AWS profile used to authenticate. +[ profile: ] + +# AWS Role ARN, an alternative to using AWS API keys. +[ role_arn: ] + +# Refresh interval to re-read the targets list. +[ refresh_interval: | default = 60s ] + +# The port to scrape metrics from. If using the public IP address, this must +# instead be specified in the relabeling rule. +[ port: | default = 80 ] + +# Filters can be used optionally to filter the instance list by other criteria (ec2 role only). +# Available filter criteria can be found here: +# https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeInstances.html +# Filter API documentation: https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_Filter.html +filters: + [ - name: + values: , [...] ] + +# List of ECS, ElastiCache, or MSK cluster identifiers (ecs, elasticache, and msk roles only) to discover. +# A List of ARNs of clusters to discover. If empty, all clusters in the region are discovered. +# This can significantly improve performance when you only need to monitor specific clusters/caches. +[ clusters: [, ...] ] + +# HTTP client settings, including authentication methods (such as basic auth and +# authorization), proxy configurations, TLS options, custom HTTP headers, etc. +[ ] +``` + ### `` Azure SD configurations allow retrieving scrape targets from Azure VMs. @@ -2262,8 +2635,7 @@ in the configuration file), which can also be changed using relabeling. ### `` -Nerve SD configurations allow retrieving scrape targets from [AirBnB's Nerve] -(https://github.com/airbnb/nerve) which are stored in +Nerve SD configurations allow retrieving scrape targets from [AirBnB's Nerve](https://github.com/airbnb/nerve) which are stored in [Zookeeper](https://zookeeper.apache.org/). The following meta labels are available on targets during [relabeling](#relabel_config): @@ -2317,8 +2689,7 @@ The following meta labels are available on targets during [relabeling](#relabel_ ### `` -Serverset SD configurations allow retrieving scrape targets from [Serversets] -(https://github.com/twitter/finagle/tree/develop/finagle-serversets) which are +Serverset SD configurations allow retrieving scrape targets from [Serversets](https://github.com/twitter/finagle/tree/develop/finagle-serversets) which are stored in [Zookeeper](https://zookeeper.apache.org/). Serversets are commonly used by [Finagle](https://twitter.github.io/finagle/) and [Aurora](https://aurora.apache.org/). @@ -2401,12 +2772,35 @@ project: [ ] ``` -A Service Account Token can be set through `http_config`. +A [Service Account Key](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-keys/) can be set through `http_config`. This can be done mapping values from STACKIT Service Account json into oauth2 configuration. + +From a given Service Account json +```json +{ + //.... + "credentials": { + "kid": "6a7c3b36-xxxxxxxx", + "iss": "xxxx@sa.stackit.cloud", + "sub": "af2c2336-xxxxxxxx", + "aud": "https://stackit-service-account-prod.apps.01.cf.eu01.stackit.cloud", + "privateKey": "-----BEGIN PRIVATE KEY-----xxxx" + } +} +``` + +properties can be mapped as: ```yaml stackit_sd_config: -- authorization: - credentials: +- oauth2: + client_id: + client_certificate_key: + client_certificate_key_id: + iss: + audience: + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer" + token_url: "https://service-account.api.stackit.cloud/token" + signature_algorithm: RS512 ``` ### `` @@ -2729,6 +3123,11 @@ labels: [ : ... ] ``` +The special labels mentioned in the [relabeling](#relabel_config) section can also be +used here to override the respective settings in the scrape configuration. This is +especially useful when combined with any of the service discovery mechanisms that do not +support these settings directly. + ### `` Relabeling is a powerful tool to dynamically rewrite the label set of a target before @@ -2738,6 +3137,11 @@ in the configuration file. Initially, aside from the configured per-target labels, a target's `job` label is set to the `job_name` value of the respective scrape configuration. + +You can also use special labels like `__address__`, `__scheme__`, `__metrics_path__`, +`__scrape_interval__`, `__scrape_timeout__` to customize the defined targets. These will +override the respective settings in the scrape configuration. + The `__address__` label is set to the `:` address of the target. After relabeling, the `instance` label is set to the value of `__address__` by default if it was not set during relabeling. @@ -2881,10 +3285,23 @@ sigv4: # AWS Role ARN, an alternative to using AWS API keys. [ role_arn: ] + # AWS External ID used when assuming a role. + # Can only be used with role_arn. + [ external_id: ] + + # Defines the FIPS mode for the AWS STS endpoint. + # Requires Prometheus >= 2.54.0 + # Note: FIPS STS selection should be configured via use_fips_sts_endpoint rather than environment variables. (The problem report that motivated this: AWS_USE_FIPS_ENDPOINT no longer works.) + [ use_fips_sts_endpoint: | default = false ] + # HTTP client settings, including authentication methods (such as basic auth and # authorization), proxy configurations, TLS options, custom HTTP headers, etc. [ ] +# List of AWS service discovery configurations. +aws_sd_configs: + [ - ... ] + # List of Azure service discovery configurations. azure_sd_configs: [ - ... ] @@ -3083,6 +3500,15 @@ sigv4: # AWS Role ARN, an alternative to using AWS API keys. [ role_arn: ] + # AWS External ID used when assuming a role. + # Can only be used with role_arn. + [ external_id: ] + + # Defines the FIPS mode for the AWS STS endpoint. + # Requires Prometheus >= 2.54.0 + # Note: FIPS STS selection should be configured via use_fips_sts_endpoint rather than environment variables. (The problem report that motivated this: AWS_USE_FIPS_ENDPOINT no longer works.) + [ use_fips_sts_endpoint: | default = false ] + # Optional AzureAD configuration. # Cannot be used at the same time as basic_auth, authorization, oauth2, sigv4 or google_iam. azuread: @@ -3110,6 +3536,14 @@ azuread: [ sdk: [ tenant_id: ] ] + # Optional custom OAuth 2.0 scope to request when acquiring tokens. + # If not specified, defaults to the appropriate monitoring scope for the cloud: + # - AzurePublic: https://monitor.azure.com//.default + # - AzureGovernment: https://monitor.azure.us//.default + # - AzureChina: https://monitor.azure.cn//.default + # Use this to authenticate against custom Azure applications or non-standard endpoints. + [ scope: ] + # WARNING: Remote write is NOT SUPPORTED by Google Cloud. This configuration is reserved for future use. # Optional Google Cloud Monitoring configuration. # Cannot be used at the same time as basic_auth, authorization, oauth2, sigv4 or azuread. @@ -3230,6 +3664,19 @@ with this feature. # to the timestamp of the last appended sample for the same series. [ out_of_order_time_window: | default = 0s ] +# Configures the trigger point for compacting the stale series from the memory into persistent blocks +# and remove those stale series from the memory. +# +# The threshold is a number between 0.0 and 1.0. It represents the ratio of stale series in the memory +# to the total series in the memory. The stale series compaction is triggered when this ratio crosses +# the configured threshold. It may not trigger the stale series compaction if the usual head compaction +# is about to happen soon. +# +# If set to 0, stale series compaction is disabled. +# +# This is an experimental feature, this behaviour could change or be removed in the future. +[ stale_series_compaction_threshold: | default = 0 ] + # Configures data retention settings for TSDB. # diff --git a/docs/configuration/unit_testing_rules.md b/docs/configuration/unit_testing_rules.md index d237c8cf88..af94c414f0 100644 --- a/docs/configuration/unit_testing_rules.md +++ b/docs/configuration/unit_testing_rules.md @@ -48,6 +48,18 @@ input_series: # Name of the test group [ name: ] +# Start timestamp for the test group. This sets the base time for all samples +# and evaluations in this test group. +# Accepts either a Unix timestamp (e.g., 1609459200) or an RFC3339 formatted +# timestamp (e.g., "2021-01-01T00:00:00Z"). +# Default: 0 (Unix epoch: 1970-01-01 00:00:00 UTC) +# +# When set: +# - All input_series samples are timestamped starting from start_timestamp +# - The eval_time in test cases is relative to start_timestamp +# - The time() function returns start_timestamp + eval_time +[ start_timestamp: | | default = 0 ] + # Unit tests for the above data. # Unit tests for alerting rules. We consider the alerting rules from the input file. @@ -137,7 +149,8 @@ values: Prometheus allows you to have same alertname for different alerting rules. Hence in this unit testing, you have to list the union of all the firing alerts for the alertname under a single ``. ``` yaml -# The time elapsed from time=0s when the alerts have to be checked. +# The time elapsed from start_timestamp when the alerts have to be checked. +# This is a duration relative to start_timestamp (which defaults to 0). eval_time: # Name of the alert to be tested. @@ -168,7 +181,8 @@ exp_annotations: # Expression to evaluate expr: -# The time elapsed from time=0s when the expression has to be evaluated. +# The time elapsed from start_timestamp when the expression has to be evaluated. +# This is a duration relative to start_timestamp (which defaults to 0). eval_time: # Expected samples at the given evaluation time. @@ -275,3 +289,24 @@ groups: summary: "Instance {{ $labels.instance }} down" description: "{{ $labels.instance }} of job {{ $labels.job }} has been down for more than 5 minutes." ``` + +### Time within tests + +It should be noted that in all tests, either in `alert_test_case` or +`promql_test_case`, the output from all functions related to the current time, +for example the `time()` and `day_of_*()` functions, will output a consistent value +for tests. + +By default, at the start of the test evaluation, `time()` returns 0 (Unix epoch: +January 1, 1970 00:00:00 UTC). The `eval_time` field specifies a duration relative +to `start_timestamp`, so by default `time()` will return a value of `0 + eval_time`. + +You can configure a custom start timestamp for your tests by setting the `start_timestamp` +field in your test group. This field accepts either: +- A Unix timestamp (e.g., `1609459200` for January 1, 2021 00:00:00 UTC) +- An RFC3339 formatted timestamp (e.g., `"2021-01-01T00:00:00Z"`) + +When you set `start_timestamp`: +- All `input_series` samples will be timestamped starting from `start_timestamp` +- The `eval_time` field in test cases is interpreted as a duration relative to `start_timestamp` +- The `time()` function will return `start_timestamp + eval_time` diff --git a/docs/feature_flags.md b/docs/feature_flags.md index 1b3c21aae8..247941c5ce 100644 --- a/docs/feature_flags.md +++ b/docs/feature_flags.md @@ -28,6 +28,8 @@ and m-mapped chunks, while a WAL replay from disk is only needed for the parts o `--enable-feature=extra-scrape-metrics` +> **Note:** This feature flag is deprecated. Please use the `extra_scrape_metrics` configuration option instead (available at both global and scrape-config level). The feature flag will be removed in a future major version. See the [configuration documentation](configuration/configuration.md) for more details. + When enabled, for each instance scrape, Prometheus stores a sample in the following additional time series: - `scrape_timeout_seconds`. The configured `scrape_timeout` for a target. This allows you to measure each target to find out how close they are to timing out with `scrape_duration_seconds / scrape_timeout_seconds`. @@ -45,20 +47,6 @@ statistics. Currently this is limited to totalQueryableSamples. When disabled in either the engine or the query, per-step statistics are not computed at all. -## Native Histograms - -`--enable-feature=native-histograms` - -_This feature flag is being phased out. You should not use it anymore._ - -Native histograms are a stable feature by now. However, to scrape native -histograms, a scrape config setting `scrape_native_histograms` is required. To -ease the transition, this feature flag sets the default value of -`scrape_native_histograms` to `true`. From v3.9 on, this feature flag will be a -true no-op, and the default value of `scrape_native_histograms` will be always -`false`. If you are still using this feature flag while running v3.8, update -your scrape configs and stop using the feature flag before upgrading to v3.9. - ## Experimental PromQL functions `--enable-feature=promql-experimental-functions` @@ -67,20 +55,27 @@ Enables PromQL functions that are considered experimental. These functions might change their name, syntax, or semantics. They might also get removed entirely. -## Created Timestamps Zero Injection +## Start (Created) Timestamps Zero Injection `--enable-feature=created-timestamp-zero-ingestion` -Enables ingestion of created timestamp. Created timestamps are injected as 0 valued samples when appropriate. See [PromCon talk](https://youtu.be/nWf0BfQ5EEA) for details. +> NOTE: CreatedTimestamp feature was renamed to StartTimestamp for consistency. The above flag uses old name for stability. -Currently Prometheus supports created timestamps only on the traditional -Prometheus Protobuf protocol (WIP for other protocols). Therefore, enabling -this feature pre-sets the global `scrape_protocols` configuration option to -`[ PrometheusProto, OpenMetricsText1.0.0, OpenMetricsText0.0.1, PrometheusText0.0.4 ]`, -resulting in negotiating the Prometheus Protobuf protocol with first priority -(unless the `scrape_protocols` option is set to a different value explicitly). +Enables ingestion of start timestamp. Start timestamps are injected as 0 valued samples when appropriate. See [PromCon talk](https://youtu.be/nWf0BfQ5EEA) for details. -Besides enabling this feature in Prometheus, created timestamps need to be exposed by the application being scraped. +Currently, Prometheus supports start timestamps on the + +* `PrometheusProto` +* `OpenMetrics1.0.0` + + +From the above, Prometheus recommends `PrometheusProto`. This is because OpenMetrics 1.0 Start Timestamp information is shared as a `_created` metric and parsing those +are prone to errors and expensive (thus, adding an overhead). You also need to be careful to not pollute your Prometheus with extra `_created` metrics. + +Therefore, when `created-timestamp-zero-ingestion` is enabled Prometheus changes the global `scrape_protocols` default configuration option to +`[ PrometheusProto, OpenMetricsText1.0.0, OpenMetricsText0.0.1, PrometheusText0.0.4 ]`, resulting in negotiating the Prometheus Protobuf protocol first (unless the `scrape_protocols` option is set to a different value explicitly). + +Besides enabling this feature in Prometheus, start timestamps need to be exposed by the application being scraped. ## Concurrent evaluation of independent rules @@ -143,6 +138,8 @@ These queries are rare to occur and easy to fix. (In the above example, removing `by (__name__)` doesn't change anything without the feature flag and fixes the possible problem with the feature flag.) +It is possible to craft a query that aggregates by `__name__` and puts samples with and without delayed name removal into the same group. In that case, the name is removed from the affected group. Note that this case hardly occurs in queries that fulfill a practical purpose. + ## Auto Reload Config `--enable-feature=auto-reload-config` @@ -202,7 +199,12 @@ the offset calculation. `step()` can be used in duration expressions. For a **range query**, it resolves to the step width of the range query. -For an **instant query**, it resolves to `0s`. +For an **instant query**, it resolves to `0s`. + +`range()` can be used in duration expressions. +For a **range query**, it resolves to the full range of the query (end time - start time). +For an **instant query**, it resolves to `0s`. +This is particularly useful in combination with `@end()` to look back over the entire query range, e.g., `max_over_time(metric[range()] @ end())`. `min(, )` and `max(, )` can be used to find the minimum or maximum of two duration expressions. @@ -286,8 +288,8 @@ when wrong types are used on wrong functions, automatic renames, delta types and ### Behavior with metadata records -When this feature is enabled and the metadata WAL records exists, in an unlikely situation when type or unit are different across those, -the Prometheus outputs intends to prefer the `__type__` and `__unit__` labels values. For example on Remote Write 2.0, +When this feature is enabled and the metadata WAL records exists, in an unlikely situation when type or unit are different across those, +the Prometheus outputs intends to prefer the `__type__` and `__unit__` labels values. For example on Remote Write 2.0, if the metadata record somehow (e.g. due to bug) says "counter", but `__type__="gauge"` the remote time series will be set to a gauge. ## Use Uncached IO @@ -336,9 +338,25 @@ Example query: > **Note for alerting and recording rules:** > The `smoothed` modifier requires samples after the evaluation interval, so using it directly in alerting or recording rules will typically *under-estimate* the result, as future samples are not available at evaluation time. -> To use `smoothed` safely in rules, you **must** apply a `query_offset` to the rule group (see [documentation](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/#rule_group)) to ensure the calculation window is fully in the past and all needed samples are available. +> To use `smoothed` safely in rules, you **must** apply a `query_offset` to the rule group (see [documentation](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/#rule_group)) to ensure the calculation window is fully in the past and all needed samples are available. > For critical alerting, set the offset to at least one scrape interval; for less critical or more resilient use cases, consider a larger offset (multiple scrape intervals) to tolerate missed scrapes. For more details, see the [design doc](https://github.com/prometheus/proposals/blob/main/proposals/2025-04-04_extended-range-selectors-semantics.md). **Note**: Extended Range Selectors are not supported for subqueries. + +## Binary operator fill modifiers + +`--enable-feature=promql-binop-fill-modifiers` + +Enables experimental `fill()`, `fill_left()`, and `fill_right()` modifiers for PromQL binary operators. These modifiers allow filling in missing matches on either side of a binary operation with a provided default sample value. + +Example query: + +``` + rate(successful_requests[5m]) ++ fill(0) + rate(failed_requests[5m]) +``` + +See [the fill modifiers documentation](querying/operators.md#filling-in-missing-matches) for more details and examples. diff --git a/docs/http_sd.md b/docs/http_sd.md index 3bd6bada39..d329ce07af 100644 --- a/docs/http_sd.md +++ b/docs/http_sd.md @@ -39,8 +39,9 @@ an empty list `[]`. Target lists are unordered. Prometheus caches target lists. If an error occurs while fetching an updated targets list, Prometheus keeps using the current targets list. The targets list -is not saved across restart. The `prometheus_sd_http_failures_total` counter -metric tracks the number of refresh failures. +is not saved across restart. The `prometheus_sd_refresh_failures_total` counter +metric tracks the number of refresh failures and the `prometheus_sd_refresh_duration_seconds` +bucket can be used to track HTTP SD refresh attempts or performance. The whole list of targets must be returned on every scrape. There is no support for incremental updates. A Prometheus instance does not send its hostname and it diff --git a/docs/images/prometheus_agent.png b/docs/images/prometheus_agent.png new file mode 100644 index 0000000000..01a9f00f39 Binary files /dev/null and b/docs/images/prometheus_agent.png differ diff --git a/docs/index.md b/docs/index.md index d9d4d2b152..fff28fa54a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ The documentation is available alongside all the project documentation at - [Getting started](getting_started.md) - [Installation](installation.md) +- [Agent Mode](prometheus_agent.md) - [Configuration](configuration/configuration.md) - [Querying](querying/basics.md) - [Storage](storage.md) diff --git a/docs/migration.md b/docs/migration.md index 78db5b7d0f..6a08373f5c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -43,18 +43,19 @@ This document offers guidance on migrating from Prometheus 2.x to Prometheus 3.0 Prometheus v3 will log a warning if you continue to pass these to `--enable-feature`. -- Starting from Prometheus version v3.8, the feature flag `native-histograms` is - deprecated. Use the new `scrape_native_histograms` global and per-scrape - configuration option instead. +- Starting from v3.9, the feature flag `native-histograms` is a no-op. Native + histograms are a stable feature now, but scraping them has to be enabled via + the `scrape_native_histograms` global or per-scrape configuration option + (added in v3.8). ## Configuration -- The scrape job level configuration option `scrape_classic_histograms` has been - renamed to `always_scrape_classic_histograms`. If you use the - `--enable-feature=native-histograms` feature flag to ingest native histograms - and you also want to ingest classic histograms that an endpoint might expose - along with native histograms, be sure to add this configuration or change your - configuration from the old name. +- The scrape job level configuration option `scrape_classic_histograms` has + been renamed to `always_scrape_classic_histograms`. If you use the + `scrape_native_histograms` scrape configuration option to ingest native + histograms and you also want to ingest classic histograms that an endpoint + might expose along with native histograms, be sure to add this configuration + or change your configuration from the old name. - The `http_config.enable_http2` in `remote_write` items default has been changed to `false`. In Prometheus v2 the remote write http client would default to use http2. In order to parallelize multiple remote write queues diff --git a/docs/prometheus_agent.md b/docs/prometheus_agent.md new file mode 100644 index 0000000000..0d8c3fa94a --- /dev/null +++ b/docs/prometheus_agent.md @@ -0,0 +1,54 @@ +--- +title: Prometheus Agent Mode +nav_title: Agent Mode +sort_rank: 4 +--- + +## Prometheus Agent Mode + +The Prometheus Agent is an operational mode built into the Prometheus binary with the same scraping APIs, semantics, configuration, and discovery mechanism; this agent mode disables some of Prometheus' usual features(TSDB, alerting, and rule evaluations) and optimizes the binary for scraping and remote writing to remote locations. + +The Prometheus Remote Write protocol forwards(streams) all or a subset of metrics collected by Prometheus to a remote location; you can configure Prometheus to forward some metrics (if you want, with all metadata and exemplars!) to one or more locations that support the Remote Write API. + +With Remote Write, Prometheus still uses a pull model to gather metrics from applications, which gives us an understanding of those different failure modes. After that, we batch samples and series and export, replicate (push) data to the Remote Write endpoints, limiting the number of monitoring unknowns that the central point has. +Streaming data from such a scraper enables Global View use cases by allowing you to store metrics data in a centralized location. This enables the separation of concerns, which is useful when different teams manage applications than the observability or monitoring pipelines. + +The Agent mode optimizes Prometheus for the remote write use case. It disables querying, alerting, and local storage and replaces it with a customized TSDB WAL. Everything else stays the same: scraping logic, service discovery, and related configuration. It can be used as a drop-in replacement for Prometheus if you want to just forward your data to a remote Prometheus server or any other Remote-Write-compliant project. + +In essence, it looks like this: +![Prometheus Agent Remote Write](./images/prometheus_agent.png) + +### Benefits of agent mode + +- Improved efficiency. The customized Agent TSDB WAL removes the data immediately after successful writes. If it cannot reach the remote endpoint, it persists the data temporarily on the disk until the remote endpoint is back online. This is currently limited to a two-hour buffer only, similar to non-agent Prometheus. This means that there is no need to build chunks of data in memory or maintain a full index for querying purposes. Essentially the Agent mode uses a fraction of the resources that a normal Prometheus server would use in a similar situation. +- Agent mode enables easier [horizontal scalability for ingestion](https://prometheus.io/blog/2021/11/16/agent/#the-dream-auto-scalable-metric-ingestion). + +### Downsides of agent mode + +- No local queries. You can not query the local Prometheus instance. +- Recording rules are not possible. You can not pre-summarize data for sending to remote write. Rules must be done remotely. +- No alerting. All alerting must be done by the remote system. + +### How to Use Agent Mode in Detail + +If you show the help output of Prometheus (--help flag), you should see more or less the following: + +``` +usage: prometheus [] + +The Prometheus monitoring server + +Flags: + -h, --help Show context-sensitive help (also try --help-long and --help-man). + (... other flags) + --storage.tsdb.path="data/" + Base path for metrics storage. Use with server mode only. + --storage.agent.path="data-agent/" + Base path for metrics storage. Use with agent mode only. + (... other flags) + --[no-]agent Run Prometheus in 'Agent mode'. +``` + +Use the `--agent` flag to run Prometheus in the Agent mode. The rest of the flags are either for both server and Agent or only for a specific mode. You can see which flag is for which mode by checking the last sentence of a flag's help string. "Use with server mode only" means it's only for server mode. If you don't see any mention like this, it means the flag is shared. + +The Agent mode accepts the same scrape configuration with the same discovery options and remote write options. It also exposes a web UI on port 9095 with disabled query capabilities but shows build info, configuration, targets, and service discovery information as in a normal Prometheus server. diff --git a/docs/querying/api.md b/docs/querying/api.md index b377c6174e..78574ec103 100644 --- a/docs/querying/api.md +++ b/docs/querying/api.md @@ -6,6 +6,22 @@ sort_rank: 7 The current stable HTTP API is reachable under `/api/v1` on a Prometheus server. Any non-breaking additions will be added under that endpoint. +## OpenAPI Specification + +An OpenAPI specification for the HTTP API is available at `/api/v1/openapi.yaml`. +By default, it returns OpenAPI 3.1 for broader compatibility. Use `?openapi_version=3.2` +for OpenAPI 3.2, which includes advanced features and endpoints like `/api/v1/notifications/live`. + +This machine-readable specification describes all available endpoints, request parameters, +response formats, and schemas. + +The OpenAPI specification can be used to: + +- Generate client libraries in various programming languages. +- Validate API requests and responses. +- Generate interactive API documentation. +- Test API endpoints. + ## Format overview The API response format is JSON. Every successful API request returns a `2xx` @@ -84,8 +100,9 @@ URL query parameters: - `time=`: Evaluation timestamp. Optional. - `timeout=`: Evaluation timeout. Optional. Defaults to and is capped by the value of the `-query.timeout` flag. -- `limit=`: Maximum number of returned series. Doesn’t affect scalars or strings but truncates the number of series for matrices and vectors. Optional. 0 means disabled. +- `limit=`: Maximum number of returned series. Doesn't affect scalars or strings but truncates the number of series for matrices and vectors. Optional. 0 means disabled. - `lookback_delta=`: Override the the [lookback period](#staleness) just for this query. Optional. +- `stats=`: Include query statistics in the response. If set to `all`, includes detailed statistics. Optional. The current server time is used if the `time` parameter is omitted. @@ -159,6 +176,7 @@ URL query parameters: is capped by the value of the `-query.timeout` flag. - `limit=`: Maximum number of returned series. Optional. 0 means disabled. - `lookback_delta=`: Override the the [lookback period](#staleness) just for this query. Optional. +- `stats=`: Include query statistics in the response. If set to `all`, includes detailed statistics. Optional. You can URL-encode these parameters directly in the request body by using the `POST` method and `Content-Type: application/x-www-form-urlencoded` header. This is useful when specifying a large @@ -670,6 +688,35 @@ Note that with the currently implemented bucket schemas, positive buckets are “open left”, negative buckets are “open right”, and the zero bucket (with a negative left boundary and a positive right boundary) is “closed both”. +## Scrape pools + +The following endpoint returns a list of all configured scrape pools: + +``` +GET /api/v1/scrape_pools +``` + +The `data` section of the JSON response is a list of string scrape pool names. + +```bash +curl http://localhost:9090/api/v1/scrape_pools +``` + +```json +{ + "status": "success", + "data": { + "scrapePools": [ + "prometheus", + "node_exporter", + "blackbox" + ] + } +} +``` + +*New in v2.42* + ## Targets The following endpoint returns an overview of the current state of the @@ -982,6 +1029,7 @@ curl http://localhost:9090/api/v1/alerts ## Querying target metadata The following endpoint returns metadata about metrics currently scraped from targets. +The endpoint has the limitation that only metadata scraped from targets directly is returned, metadata sent over Remote-Write or OTLP to Prometheus is not included in this endpoint and will not show up on the UI in "Explore Metrics". This is **experimental** and might change in the future. ``` @@ -1346,7 +1394,7 @@ GET /api/v1/status/tsdb ``` URL query parameters: -- `limit=`: Limit the number of returned items to a given number for each set of statistics. By default, 10 items are returned. +- `limit=`: Limit the number of returned items to a given number for each set of statistics. By default, 10 items are returned. The maximum allowed limit is 10000. The `data` section of the query result consists of: @@ -1700,3 +1748,80 @@ GET /api/v1/notifications/live ``` *New in v3.0* + +### Features + +The following endpoint returns a list of enabled features in the Prometheus server: + +``` +GET /api/v1/features +``` + +This endpoint provides information about which features are currently enabled or disabled in the Prometheus instance. Features are organized into categories such as `api`, `promql`, `promql_functions`, etc. + +The `data` section contains a map where each key is a feature category, and each value is a map of feature names to their enabled status (boolean). + +```bash +curl http://localhost:9090/api/v1/features +``` + +```json +{ + "status": "success", + "data": { + "api": { + "admin": false, + "exclude_alerts": true + }, + "otlp_receiver": { + "delta_conversion": false, + "native_delta_ingestion": false + }, + "prometheus": { + "agent_mode": false, + "auto_reload_config": false + }, + "promql": { + "anchored": false, + "at_modifier": true + }, + "promql_functions": { + "abs": true, + "absent": true + }, + "promql_operators": { + "!=": true, + "!~": true + }, + "rules": { + "concurrent_rule_eval": false, + "keep_firing_for": true + }, + "scrape": { + "start_timestamp_zero_ingestion": false, + "extra_metrics": false + }, + "service_discovery": { + "azure": true, + "consul": true + }, + "templating": { + "args": true, + "externalURL": true + }, + "tsdb": { + "delayed_compaction": false, + "exemplar_storage": false + } + } +} +``` + +**Notes:** + +- All feature names use `snake_case` naming convention +- Features set to `false` may be omitted from the response +- Clients should treat absent features as equivalent to `false` +- Clients must ignore unknown feature names and categories for forward compatibility + +*New in v3.8* diff --git a/docs/querying/functions.md b/docs/querying/functions.md index 0cae149dd7..68a003359d 100644 --- a/docs/querying/functions.md +++ b/docs/querying/functions.md @@ -433,6 +433,23 @@ and is therefore flagged by an info-level annotation reading `input to histogram_quantile needed to be fixed for monotonicity`. If you encounter this annotation, you should find and remove the source of the invalid data. +## `histogram_quantiles()` + +**This function has to be enabled via the [feature +flag](../feature_flags.md#experimental-promql-functions) +`--enable-feature=promql-experimental-functions`.** + +`histogram_quantiles(v instant-vector, quantile_label string, φ_1 scalar, φ_2 scalar, ...)` calculates multiple (between 1 and 10) φ-quantiles (0 ≤ +φ ≤ 1) from a [classic +histogram](https://prometheus.io/docs/concepts/metric_types/#histogram) or from +a native histogram. Quantile calculation works the same way as in `histogram_quantile()`. +The second argument (a string) specifies the label name that is used to identify different quantiles in the query result. +``` +histogram_quantiles(sum(rate(foo[1m])), "quantile", 0.9, 0.99) +# => {quantile="0.9"} 123 + {quantile="0.99"} 128 +``` + ## `histogram_stddev()` and `histogram_stdvar()` `histogram_stddev(v instant-vector)` returns the estimated standard deviation @@ -568,6 +585,8 @@ While `info` normally automatically finds all matching info series, it's possibl restrict them by providing a `__name__` label matcher, e.g. `{__name__="target_info"}`. +Note that if there are any time series in `v` that match the `data-label-selector` (or the default `target_info` if that argument is not specified), they will be treated as info series and will be returned unchanged. + ### Limitations In its current iteration, `info` defaults to considering only info series with diff --git a/docs/querying/operators.md b/docs/querying/operators.md index 9aaae3fbcf..b15c02aedc 100644 --- a/docs/querying/operators.md +++ b/docs/querying/operators.md @@ -47,9 +47,9 @@ special values like `NaN`, `+Inf`, and `-Inf`. scalar that is the result of the operator applied to both scalar operands. **Between an instant vector and a scalar**, the operator is applied to the -value of every data sample in the vector. +value of every data sample in the vector. -If the data sample is a float, the operation is performed between that float and the scalar. +If the data sample is a float, the operation is performed between that float and the scalar. For example, if an instant vector of float samples is multiplied by 2, the result is another vector of float samples in which every sample value of the original vector is multiplied by 2. @@ -81,8 +81,9 @@ following: **Between two instant vectors**, a binary arithmetic operator is applied to each entry in the LHS vector and its [matching element](#vector-matching) in the RHS vector. The result is propagated into the result vector with the -grouping labels becoming the output label set. Entries for which no matching -entry in the right-hand vector can be found are not part of the result. +grouping labels becoming the output label set. By default, series for which +no matching entry in the opposite vector can be found are not part of the +result. This behavior can be adjusted using [fill modifiers](#filling-in-missing-matches). If two float samples are matched, the arithmetic operator is applied to the two input values. @@ -97,7 +98,7 @@ If two histogram samples are matched, only `+` and `-` are valid operations, each adding or subtracting all matching bucket populations and the count and the sum of observations. All other operations result in the removal of the corresponding element from the output vector, flagged by an info-level -annotation. The `+` and -` operations should generally only be applied to gauge +annotation. The `+` and `-` operations should generally only be applied to gauge histograms, but PromQL allows them for counter histograms, too, to cover specific use cases, for which special attention is required to avoid problems with unaligned counter resets. (Certain incompatibilities of counter resets can @@ -106,7 +107,7 @@ two counter histograms results in a counter histogram. All other combination of operands and all subtractions result in a gauge histogram. **In any arithmetic binary operation involving vectors**, the metric name is -dropped. This occurs even if `__name__` is explicitly mentioned in `on` +dropped. This occurs even if `__name__` is explicitly mentioned in `on` (see https://github.com/prometheus/prometheus/issues/16631 for further discussion). **For any arithmetic binary operation that may result in a negative @@ -156,9 +157,9 @@ info-level annotation. applied to matching entries. Vector elements for which the expression is not true or which do not find a match on the other side of the expression get dropped from the result, while the others are propagated into a result vector -with the grouping labels becoming the output label set. +with the grouping labels becoming the output label set. -Matches between two float samples work as usual. +Matches between two float samples work as usual. Matches between a float sample and a histogram sample are invalid, and the corresponding element is removed from the result vector, flagged by an info-level @@ -171,8 +172,8 @@ comparison binary operations are again invalid. modifier changes the behavior in the following ways: * Vector elements which find a match on the other side of the expression but for - which the expression is false instead have the value `0` and vector elements - that do find a match and for which the expression is true have the value `1`. + which the expression is false instead have the value `0`, and vector elements + that do find a match and for which the expression is true have the value `1`. (Note that elements with no match or invalid operations involving histogram samples still return no result rather than the value `0`.) * The metric name is dropped. @@ -216,11 +217,10 @@ matching behavior: One-to-one and many-to-one/one-to-many. ### Vector matching keywords -These vector matching keywords allow for matching between series with different label sets -providing: +These vector matching keywords allow for matching between series with different label sets: -* `on` -* `ignoring` +* `on(
  • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
  • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
  • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
  • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
  • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
  • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
  • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
  • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
  • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
  • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -526,32 +531,75 @@ const funcDocs: Record = {

    If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

    • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
    • +
    • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
    • +
    • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
    • +
    • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
    • +
    • + first_over_time(range-vector): the oldest sample in the specified interval. +
    • +
    • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

    - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

    +

    These functions act on histograms in the following way:

    + +
      +
    • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
    • +
    • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
    • +
    • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
    • +
    +

    - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

    ), ceil: ( <>

    - ceil(v instant-vector) rounds the sample values of all elements in v up to the nearest - integer value greater than or equal to v. + ceil(v instant-vector) returns a vector containing all float samples in the input vector rounded up + to the nearest integer value greater than or equal to their original value. Histogram samples in the input + vector are ignored silently.

      @@ -573,17 +621,19 @@ const funcDocs: Record = { changes: ( <>

      - For each input time series, changes(v range-vector) returns the number of times its value has changed - within the provided time range as an instant vector. + For each input time series, changes(v range-vector) returns the number of times its value has + changed within the provided time range as an instant vector. A float sample followed by a histogram sample, or + vice versa, counts as a change. A counter histogram sample followed by a gauge histogram sample with otherwise + exactly the same values, or vice versa, does not count as a change.

      ), clamp: ( <>

      - clamp(v instant-vector, min scalar, max scalar) - clamps the sample values of all elements in v to have a lower limit of min and an upper - limit of max. + clamp(v instant-vector, min scalar, max scalar) clamps the values of all float samples in{" "} + v to have a lower limit of min and an upper limit of + max. Histogram samples in the input vector are ignored silently.

      Special cases:

      @@ -593,7 +643,7 @@ const funcDocs: Record = { Return an empty vector if min > max
    • - Return NaN if min or max is NaN + Float samples are clamped to NaN if min or max is NaN
    @@ -601,71 +651,71 @@ const funcDocs: Record = { clamp_max: ( <>

    - clamp_max(v instant-vector, max scalar) clamps the sample values of all elements in v to - have an upper limit of max. + clamp_max(v instant-vector, max scalar) clamps the values of all float samples in v to + have an upper limit of max. Histogram samples in the input vector are ignored silently.

    ), clamp_min: ( <>

    - clamp_min(v instant-vector, min scalar) clamps the sample values of all elements in v to - have a lower limit of min. + clamp_min(v instant-vector, min scalar) clamps the values of all float samples in v to + have a lower limit of min. Histogram samples in the input vector are ignored silently.

    ), cos: ( <> -

    The trigonometric functions work in radians:

    +

    The trigonometric functions work in radians. They ignore histogram samples in the input vector.

    • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
    • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
    • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
    • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
    • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
    • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
    • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
    • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
    • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
    • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
    • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
    • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
    @@ -673,69 +723,69 @@ const funcDocs: Record = {
    • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
    • pi(): returns pi.
    • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
    ), cosh: ( <> -

    The trigonometric functions work in radians:

    +

    The trigonometric functions work in radians. They ignore histogram samples in the input vector.

    • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
    • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
    • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
    • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
    • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
    • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
    • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
    • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
    • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
    • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
    • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
    • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
    @@ -743,13 +793,13 @@ const funcDocs: Record = {
    • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
    • pi(): returns pi.
    • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
    @@ -757,40 +807,42 @@ const funcDocs: Record = { count_over_time: ( <>

    - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

    • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
    • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
    • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
    • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
    • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
    • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
    • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
    • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
    • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
    • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -799,111 +851,161 @@ const funcDocs: Record = {

      If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

      • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
      • +
      • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
      • +
      • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
      • +
      • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
      • +
      • + first_over_time(range-vector): the oldest sample in the specified interval. +
      • +
      • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

      - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

      +

      These functions act on histograms in the following way:

      + +
        +
      • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
      • +
      • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
      • +
      • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
      • +
      +

      - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

      ), day_of_month: ( <>

      - day_of_month(v=vector(time()) instant-vector) returns the day of the month for each of the given times - in UTC. Returned values are from 1 to 31. + day_of_month(v=vector(time()) instant-vector) interprets float samples in + v as timestamps (number of seconds since January 1, 1970 UTC) and returns the day of the month (in + UTC) for each of those timestamps. Returned values are from 1 to 31. Histogram samples in the input vector are + ignored silently.

      ), day_of_week: ( <>

      - day_of_week(v=vector(time()) instant-vector) returns the day of the week for each of the given times in - UTC. Returned values are from 0 to 6, where 0 means Sunday etc. + day_of_week(v=vector(time()) instant-vector) interprets float samples in v + as timestamps (number of seconds since January 1, 1970 UTC) and returns the day of the week (in UTC) for each of + those timestamps. Returned values are from 0 to 6, where 0 means Sunday etc. Histogram samples in the input + vector are ignored silently.

      ), day_of_year: ( <>

      - day_of_year(v=vector(time()) instant-vector) returns the day of the year for each of the given times in - UTC. Returned values are from 1 to 365 for non-leap years, and 1 to 366 in leap years. + day_of_year(v=vector(time()) instant-vector) interprets float samples in v + as timestamps (number of seconds since January 1, 1970 UTC) and returns the day of the year (in UTC) for each of + those timestamps. Returned values are from 1 to 365 for non-leap years, and 1 to 366 in leap years. Histogram + samples in the input vector are ignored silently.

      ), days_in_month: ( <>

      - days_in_month(v=vector(time()) instant-vector) returns number of days in the month for each of the given - times in UTC. Returned values are from 28 to 31. + days_in_month(v=vector(time()) instant-vector) interprets float samples in + v as timestamps (number of seconds since January 1, 1970 UTC) and returns the number of days in the + month of each of those timestamps (in UTC). Returned values are from 28 to 31. Histogram samples in the input + vector are ignored silently.

      ), deg: ( <> -

      The trigonometric functions work in radians:

      +

      The trigonometric functions work in radians. They ignore histogram samples in the input vector.

      • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
      • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
      • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
      • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
      • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
      • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
      • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
      • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
      • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
      • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
      • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
      • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
      @@ -911,13 +1013,13 @@ const funcDocs: Record = {
      • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
      • pi(): returns pi.
      • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
      @@ -925,52 +1027,86 @@ const funcDocs: Record = { delta: ( <>

      - delta(v range-vector) calculates the difference between the first and last value of each time series - element in a range vector v, returning an instant vector with the given deltas and equivalent labels. - The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is - possible to get a non-integer result even if the sample values are all integers. + delta(v range-vector) calculates the difference between the first and last value of each time + series element in a range vector v, returning an instant vector with the given deltas and + equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector + selector, so that it is possible to get a non-integer result even if the sample values are all integers.

      The following example expression returns the difference in CPU temperature between now and 2 hours ago:

               
      -          delta(cpu_temp_celsius{'{'}host="zeus"{'}'}[2h])
      +          delta(cpu_temp_celsius{"{"}host="zeus"{"}"}[2h])
               
             

      - delta acts on native histograms by calculating a new histogram where each component (sum and count of - observations, buckets) is the difference between the respective component in the first and last native histogram in - v. However, each element in v that contains a mix of float and native histogram samples - within the range, will be missing from the result vector. + delta acts on histogram samples by calculating a new histogram where each component (sum and count + of observations, buckets) is the difference between the respective component in the first and last native + histogram in v. However, each element in v that contains a mix of float samples and + histogram samples within the range will be omitted from the result vector, flagged by a warn-level annotation.

      - delta should only be used with gauges and native histograms where the components behave like gauges - (so-called gauge histograms). + delta should only be used with gauges (for both floats and histograms).

      ), deriv: ( <>

      - deriv(v range-vector) calculates the per-second derivative of the time series in a range vector{' '} - v, using simple linear regression. - The range vector must have at least two samples in order to perform the calculation. When +Inf or + deriv(v range-vector) calculates the per-second derivative of each float time series in the range + vector v, using{" "} + simple linear regression. The range vector + must have at least two float samples in order to perform the calculation. When +Inf or{" "} -Inf are found in the range vector, the slope and offset value calculated will be NaN.

      - deriv should only be used with gauges. + deriv should only be used with gauges and only works for float samples. Elements in the range + vector that contain only histogram samples are ignored entirely. For elements that contain a mix of float and + histogram samples, only the float samples are used as input, which is flagged by an info-level annotation. +

      + + ), + double_exponential_smoothing: ( + <> +

      + + This function has to be enabled via the{" "} + feature flag + --enable-feature=promql-experimental-functions. + +

      + +

      + double_exponential_smoothing(v range-vector, sf scalar, tf scalar) produces a smoothed value for + each float time series in the range in v. The lower the smoothing factor sf, the more + importance is given to old data. The higher the trend factor tf, the more trends in the data is + considered. Both sf and + tf must be between 0 and 1. For additional details, refer to{" "} + + NIST Engineering Statistics Handbook + + . In Prometheus V2 this function was called holt_winters. This caused confusion since the + Holt-Winters method usually refers to triple exponential smoothing. Double exponential smoothing as implemented + here is also referred to as “Holt Linear”. +

      + +

      + double_exponential_smoothing should only be used with gauges and only works for float samples. + Elements in the range vector that contain only histogram samples are ignored entirely. For elements that contain + a mix of float and histogram samples, only the float samples are used as input, which is flagged by an + info-level annotation.

      ), exp: ( <>

      - exp(v instant-vector) calculates the exponential function for all elements in v. Special - cases are: + exp(v instant-vector) calculates the exponential function for all float samples in v. + Histogram samples are ignored silently. Special cases are:

        @@ -983,11 +1119,122 @@ const funcDocs: Record = {
      ), + first_over_time: ( + <> +

      + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results: +

      + +
        +
      • + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below). +
      • +
      • + min_over_time(range-vector): the minimum value of all float samples in the specified interval. +
      • +
      • + max_over_time(range-vector): the maximum value of all float samples in the specified interval. +
      • +
      • + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below). +
      • +
      • + count_over_time(range-vector): the count of all samples in the specified interval. +
      • +
      • + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval. +
      • +
      • + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval. +
      • +
      • + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval. +
      • +
      • + last_over_time(range-vector): the most recent sample in the specified interval. +
      • +
      • + present_over_time(range-vector): the value 1 for any series in the specified interval. +
      • +
      + +

      + If the feature flag + --enable-feature=promql-experimental-functions is set, the following additional functions are + available: +

      + +
        +
      • + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
      • +
      • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
      • +
      • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
      • +
      • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
      • +
      • + first_over_time(range-vector): the oldest sample in the specified interval. +
      • +
      • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval. +
      • +
      + +

      + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval. +

      + +

      These functions act on histograms in the following way:

      + +
        +
      • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
      • +
      • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
      • +
      • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
      • +
      + +

      + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step. +

      + + ), floor: ( <>

      - floor(v instant-vector) rounds the sample values of all elements in v down to the nearest - integer value smaller than or equal to v. + floor(v instant-vector) returns a vector containing all float samples in the input vector rounded + down to the nearest integer value smaller than or equal to their original value. Histogram samples in the input + vector are ignored silently.

        @@ -1009,19 +1256,13 @@ const funcDocs: Record = { histogram_avg: ( <>

        - - This function only acts on native histograms. - + histogram_avg(v instant-vector) returns the arithmetic average of observed values stored in each + histogram sample in v. Float samples are ignored and do not show up in the returned vector.

        - histogram_avg(v instant-vector) returns the arithmetic average of observed values stored in a native - histogram. Samples that are not native histograms are ignored and do not show up in the returned vector. -

        - -

        - Use histogram_avg as demonstrated below to compute the average request duration over a 5-minute window - from a native histogram: + Use histogram_avg as demonstrated below to compute the average request duration over a 5-minute + window from a native histogram:

        @@ -1032,32 +1273,28 @@ const funcDocs: Record = {
         
               
                 
        -          {' '}
        -          histogram_sum(rate(http_request_duration_seconds[5m])) / histogram_count(rate(http_request_duration_seconds[5m]))
        +          {" "}
        +          histogram_sum(rate(http_request_duration_seconds[5m])) /
        +          histogram_count(rate(http_request_duration_seconds[5m]))
                 
               
        ), - 'histogram_count()` and `histogram_sum': ( + histogram_count: ( <>

        - - Both functions only act on native histograms. - + histogram_count(v instant-vector) returns the count of observations stored in each histogram sample + in v. Float samples are ignored and do not show up in the returned vector.

        - histogram_count(v instant-vector) returns the count of observations stored in a native histogram. - Samples that are not native histograms are ignored and do not show up in the returned vector. + Similarly, histogram_sum(v instant-vector) returns the sum of observations stored in each histogram + sample.

        - Similarly, histogram_sum(v instant-vector) returns the sum of observations stored in a native histogram. -

        - -

        - Use histogram_count in the following way to calculate a rate of observations (in this case corresponding - to “requests per second”) from a native histogram: + Use histogram_count in the following way to calculate a rate of observations (in this case + corresponding to “requests per second”) from a series of histogram samples:

        @@ -1068,20 +1305,28 @@ const funcDocs: Record = {
           histogram_fraction: (
             <>
               

        - - This function only acts on native histograms. - + histogram_fraction(lower scalar, upper scalar, b instant-vector) returns the estimated fraction of + observations between the provided lower and upper values for each classic or native histogram contained in{" "} + b. Float samples in b are considered the counts of observations in each bucket of one + or more classic histograms, while native histogram samples in b are treated each individually as a + separate histogram. This works in the same way as for histogram_quantile(). (See there for more + details.)

        - For a native histogram, histogram_fraction(lower scalar, upper scalar, v instant-vector) returns the - estimated fraction of observations between the provided lower and upper values. Samples that are not native - histograms are ignored and do not show up in the returned vector. + If the provided lower and upper values do not coincide with bucket boundaries, the calculated fraction is an + estimate, using the same interpolation method as for + histogram_quantile(). (See there for more details.) Especially with classic histograms, it is easy + to accidentally pick lower or upper values that are very far away from any bucket boundary, leading to large + margins of error. Rather than using histogram_fraction() with classic histograms, it is often a + more robust approach to directly act on the bucket series when calculating fractions. See the + calculation of the Apdex score + as a typical example.

        - For example, the following expression calculates the fraction of HTTP requests over the last hour that took 200ms or - less: + For example, the following expression calculates the fraction of HTTP requests over the last hour that took + 200ms or less:

        @@ -1089,48 +1334,56 @@ const funcDocs: Record = {
               

        - The error of the estimation depends on the resolution of the underlying native histogram and how closely the provided - boundaries are aligned with the bucket boundaries in the histogram. + The error of the estimation depends on the resolution of the underlying native histogram and how closely the + provided boundaries are aligned with the bucket boundaries in the histogram.

        - +Inf and -Inf are valid boundary values. For example, if the histogram in the expression - above included negative observations (which shouldn’t be the case for request durations), the appropriate lower - boundary to include all observations less than or equal 0.2 would be -Inf rather than 0. + +Inf and -Inf are valid boundary values. For example, if the histogram in the + expression above included negative observations (which shouldn’t be the case for request durations), the + appropriate lower boundary to include all observations less than or equal 0.2 would be -Inf rather + than 0.

        - Whether the provided boundaries are inclusive or exclusive is only relevant if the provided boundaries are precisely - aligned with bucket boundaries in the underlying native histogram. In this case, the behavior depends on the schema - definition of the histogram. The currently supported schemas all feature inclusive upper boundaries and exclusive - lower boundaries for positive values (and vice versa for negative values). Without a precise alignment of boundaries, - the function uses linear interpolation to estimate the fraction. With the resulting uncertainty, it becomes - irrelevant if the boundaries are inclusive or exclusive. + Whether the provided boundaries are inclusive or exclusive is only relevant if the provided boundaries are + precisely aligned with bucket boundaries in the underlying native histogram. In this case, the behavior depends + on the schema definition of the histogram. (The usual standard exponential schemas all feature inclusive upper + boundaries and exclusive lower boundaries for positive values, and vice versa for negative values.) Without a + precise alignment of boundaries, the function uses interpolation to estimate the fraction. With the resulting + uncertainty, it becomes irrelevant if the boundaries are inclusive or exclusive. +

        + +

        + Special case for native histograms with standard exponential buckets: + NaN observations are considered outside of any buckets in this case. + histogram_fraction(-Inf, +Inf, b) effectively returns the fraction of non-NaN{" "} + observations and may therefore be less than 1.

        ), histogram_quantile: ( <>

        - histogram_quantile(φ scalar, b instant-vector) calculates the φ-quantile (0 ≤ φ ≤ 1) from a{' '} + histogram_quantile(φ scalar, b instant-vector) calculates the φ-quantile (0 ≤ φ ≤ 1) from a{" "} classic histogram or from a native - histogram. (See histograms and summaries for a detailed - explanation of φ-quantiles and the usage of the (classic) histogram metric type in general.) + histogram. (See histograms and summaries for a + detailed explanation of φ-quantiles and the usage of the (classic) histogram metric type in general.)

        - The float samples in b are considered the counts of observations in each bucket of one or more classic - histograms. Each float sample must have a label - le where the label value denotes the inclusive upper bound of the bucket. (Float samples without such a - label are silently ignored.) The other labels and the metric name are used to identify the buckets belonging to each - classic histogram. The{' '} + The float samples in b are considered the counts of observations in each bucket of one or more + classic histograms. Each float sample must have a label + le where the label value denotes the inclusive upper bound of the bucket. (Float samples without + such a label are silently ignored.) The other labels and the metric name are used to identify the buckets + belonging to each classic histogram. The{" "} histogram metric type automatically provides time series with the _bucket suffix and the appropriate labels.

        - The native histogram samples in b are treated each individually as a separate histogram to calculate the - quantile from. + The (native) histogram samples in b are treated each individually as a separate histogram to + calculate the quantile from.

        @@ -1142,10 +1395,10 @@ const funcDocs: Record = {

        - Example: A histogram metric is called http_request_duration_seconds (and therefore the metric name for - the buckets of a classic histogram is - http_request_duration_seconds_bucket). To calculate the 90th percentile of request durations over the - last 10m, use the following expression in case + Example: A histogram metric is called http_request_duration_seconds (and therefore the metric name + for the buckets of a classic histogram is + http_request_duration_seconds_bucket). To calculate the 90th percentile of request durations over + the last 10m, use the following expression in case http_request_duration_seconds is a classic histogram:

        @@ -1161,9 +1414,9 @@ const funcDocs: Record = {

        The quantile is calculated for each label combination in - http_request_duration_seconds. To aggregate, use the sum() aggregator around the{' '} + http_request_duration_seconds. To aggregate, use the sum() aggregator around the{" "} rate() function. Since the le label is required by - histogram_quantile() to deal with classic histograms, it has to be included in the by{' '} + histogram_quantile() to deal with classic histograms, it has to be included in the by{" "} clause. The following expression aggregates the 90th percentile by job for classic histograms:

        @@ -1194,23 +1447,67 @@ const funcDocs: Record = {

        - The histogram_quantile() function interpolates quantile values by assuming a linear distribution within - a bucket. + In the (common) case that a quantile value does not coincide with a bucket boundary, the{" "} + histogram_quantile() function interpolates the quantile value within the bucket the quantile value + falls into. For classic histograms, for native histograms with custom bucket boundaries, and for the zero bucket + of other native histograms, it assumes a uniform distribution of observations within the bucket (also called{" "} + linear interpolation). For the non-zero-buckets of native histograms with a standard exponential + bucketing schema, the interpolation is done under the assumption that the samples within the bucket are + distributed in a way that they would uniformly populate the buckets in a hypothetical histogram with higher + resolution. (This is also called exponential interpolation. See the{" "} + + native histogram specification + + for more details.)

        - If b has 0 observations, NaN is returned. For φ < 0, -Inf is returned. For - φ > 1, +Inf is returned. For φ = NaN, NaN is returned. + If b has 0 observations, NaN is returned. For φ < 0, -Inf is returned. + For φ > 1, +Inf is returned. For φ = NaN, NaN is returned.

        -

        - The following is only relevant for classic histograms: If b contains fewer than two buckets,{' '} - NaN is returned. The highest bucket must have an upper bound of +Inf. (Otherwise,{' '} - NaN is returned.) If a quantile is located in the highest bucket, the upper bound of the second highest - bucket is returned. A lower limit of the lowest bucket is assumed to be 0 if the upper bound of that bucket is - greater than 0. In that case, the usual linear interpolation is applied within that bucket. Otherwise, the upper - bound of the lowest bucket is returned for quantiles located in the lowest bucket. -

        +

        Special cases for classic histograms:

        + +
          +
        • + If b contains fewer than two buckets, NaN is returned. +
        • +
        • + The highest bucket must have an upper bound of +Inf. (Otherwise, NaN is returned.) +
        • +
        • + If a quantile is located in the highest bucket, the upper bound of the second highest bucket is returned. +
        • +
        • + The lower limit of the lowest bucket is assumed to be 0 if the upper bound of that bucket is greater than 0. + In that case, the usual linear interpolation is applied within that bucket. Otherwise, the upper bound of the + lowest bucket is returned for quantiles located in the lowest bucket. +
        • +
        + +

        Special cases for native histograms:

        + +
          +
        • + If a native histogram with standard exponential buckets has NaN + observations and the quantile falls into one of the existing exponential buckets, the result is skewed towards + higher values due to NaN + observations treated as +Inf. This is flagged with an info level annotation. +
        • +
        • + If a native histogram with standard exponential buckets has NaN + observations and the quantile falls above all of the existing exponential buckets, NaN is + returned. This is flagged with an info level annotation. +
        • +
        • + A zero bucket with finite width is assumed to contain no negative observations if the histogram has + observations in positive buckets, but none in negative buckets. +
        • +
        • + A zero bucket with finite width is assumed to contain no positive observations if the histogram has + observations in negative buckets, but none in positive buckets. +
        • +

        You can use histogram_quantile(0, v instant-vector) to get the estimated minimum value stored in a @@ -1227,78 +1524,127 @@ const funcDocs: Record = {

        • The counts in the buckets are monotonically increasing (strictly non-decreasing).
        • - A lack of observations between the upper limits of two consecutive buckets results in equal counts in those two - buckets. + A lack of observations between the upper limits of two consecutive buckets results in equal counts in those + two buckets.

        - However, floating point precision issues (e.g. small discrepancies introduced by computing of buckets with{' '} - sum(rate(...))) or invalid data might violate these assumptions. In that case, - histogram_quantile would be unable to return meaningful results. To mitigate the issue, + However, floating point precision issues (e.g. small discrepancies introduced by computing of buckets with{" "} + sum(rate(...))) or invalid data might violate these assumptions. In that case,{" "} + histogram_quantile would be unable to return meaningful results. To mitigate the issue,{" "} histogram_quantile assumes that tiny relative differences between consecutive buckets are happening because of floating point precision errors and ignores them. (The threshold to ignore a difference between two - buckets is a trillionth (1e-12) of the sum of both buckets.) Furthermore, if there are non-monotonic bucket counts - even after this adjustment, they are increased to the value of the previous buckets to enforce monotonicity. The - latter is evidence for an actual issue with the input data and is therefore flagged with an informational annotation - reading input to histogram_quantile needed to be fixed for monotonicity. If you encounter this - annotation, you should find and remove the source of the invalid data. + buckets is a trillionth (1e-12) of the sum of both buckets.) Furthermore, if there are non-monotonic bucket + counts even after this adjustment, they are increased to the value of the previous buckets to enforce + monotonicity. The latter is evidence for an actual issue with the input data and is therefore flagged by an + info-level annotation reading input to histogram_quantile needed to be fixed for monotonicity. If + you encounter this annotation, you should find and remove the source of the invalid data.

        ), - 'histogram_stddev()` and `histogram_stdvar': ( + histogram_quantiles: ( <>

        - - Both functions only act on native histograms. - + + This function has to be enabled via the{" "} + feature flag + --enable-feature=promql-experimental-functions. +

        - - histogram_stddev(v instant-vector) returns the estimated standard deviation of observations in a native - histogram. For this estimation, all observations in a bucket are assumed to have the value of the mean of the bucket boundaries. - For the zero bucket and for buckets with custom boundaries, the arithmetic mean is used. For the usual exponential buckets, - the geometric mean is used. Samples that are not native histograms are ignored and do not show up in the returned vector. + histogram_quantiles(v instant-vector, quantile_label string, φ_1 scalar, φ_2 scalar, ...){" "} + calculates multiple (between 1 and 10) φ-quantiles (0 ≤ φ ≤ 1) from a{" "} + classic histogram or from a native + histogram. Quantile calculation works the same way as in histogram_quantile(). The second argument + (a string) specifies the label name that is used to identify different quantiles in the query result. +

        + +
        +        
        +          histogram_quantiles(sum(rate(foo[1m])), "quantile", 0.9, 0.99) # => {"{"}quantile="0.9"
        +          {"}"} 123
        +          {"{"}quantile="0.99"{"}"} 128
        +        
        +      
        + + ), + histogram_stddev: ( + <> +

        + histogram_stddev(v instant-vector) returns the estimated standard deviation of observations for + each histogram sample in v. For this estimation, all observations in a bucket are assumed to have + the value of the mean of the bucket boundaries. For the zero bucket and for buckets with custom boundaries, the + arithmetic mean is used. For the usual exponential buckets, the geometric mean is used. Float samples are + ignored and do not show up in the returned vector.

        - Similarly, histogram_stdvar(v instant-vector) returns the estimated standard variance of observations in - a native histogram. + Similarly, histogram_stdvar(v instant-vector) returns the estimated standard variance of + observations for each histogram sample in v.

        ), - double_exponential_smoothing: ( + histogram_stdvar: ( <>

        - double_exponential_smoothing(v range-vector, sf scalar, tf scalar) produces a smoothed value for time series based on - the range in v. The lower the smoothing factor sf, the more importance is given to old - data. The higher the trend factor tf, the more trends in the data is considered. Both sf{' '} - and tf must be between 0 and 1. + histogram_stddev(v instant-vector) returns the estimated standard deviation of observations for + each histogram sample in v. For this estimation, all observations in a bucket are assumed to have + the value of the mean of the bucket boundaries. For the zero bucket and for buckets with custom boundaries, the + arithmetic mean is used. For the usual exponential buckets, the geometric mean is used. Float samples are + ignored and do not show up in the returned vector.

        - double_exponential_smoothing should only be used with gauges. + Similarly, histogram_stdvar(v instant-vector) returns the estimated standard variance of + observations for each histogram sample in v.

        ), + histogram_sum: ( + <> +

        + histogram_count(v instant-vector) returns the count of observations stored in each histogram sample + in v. Float samples are ignored and do not show up in the returned vector. +

        + +

        + Similarly, histogram_sum(v instant-vector) returns the sum of observations stored in each histogram + sample. +

        + +

        + Use histogram_count in the following way to calculate a rate of observations (in this case + corresponding to “requests per second”) from a series of histogram samples: +

        + +
        +        histogram_count(rate(http_request_duration_seconds[10m]))
        +      
        + + ), hour: ( <>

        - hour(v=vector(time()) instant-vector) returns the hour of the day for each of the given times in UTC. - Returned values are from 0 to 23. + hour(v=vector(time()) instant-vector) interprets float samples in v as timestamps + (number of seconds since January 1, 1970 UTC) and returns the hour of the day (in UTC) for each of those + timestamps. Returned values are from 0 to 23. Histogram samples in the input vector are ignored silently.

        ), idelta: ( <>

        - idelta(v range-vector) calculates the difference between the last two samples in the range vector{' '} - v, returning an instant vector with the given deltas and equivalent labels. + idelta(v range-vector) calculates the difference between the last two samples in the range vector{" "} + v, returning an instant vector with the given deltas and equivalent labels. Both samples must be + either float samples or histogram samples. Elements in v where one of the last two samples is a + float sample and the other is a histogram sample will be omitted from the result vector, flagged by a warn-level + annotation.

        - idelta should only be used with gauges. + idelta should only be used with gauges (for both floats and histograms).

        ), @@ -1307,79 +1653,214 @@ const funcDocs: Record = {

        increase(v range-vector) calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is - extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a - non-integer result even if a counter increases only by integer increments. + extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to + get a non-integer result even if a counter increases only by integer increments.

        - The following example expression returns the number of HTTP requests as measured over the last 5 minutes, per time - series in the range vector: + The following example expression returns the number of HTTP requests as measured over the last 5 minutes, per + time series in the range vector:

                 
        -          increase(http_requests_total{'{'}job="api-server"{'}'}[5m])
        +          increase(http_requests_total{"{"}job="api-server"{"}"}[5m])
                 
               

        - increase acts on native histograms by calculating a new histogram where each component (sum and count of - observations, buckets) is the increase between the respective component in the first and last native histogram in - v. However, each element in v that contains a mix of float and native histogram samples - within the range, will be missing from the result vector. + increase acts on histogram samples by calculating a new histogram where each component (sum and + count of observations, buckets) is the increase between the respective component in the first and last native + histogram in v. However, each element in v that contains a mix of float samples and + histogram samples within the range, will be omitted from the result vector, flagged by a warn-level annotation.

        - increase should only be used with counters and native histograms where the components behave like - counters. It is syntactic sugar for rate(v) multiplied by the number of seconds under the specified time - range window, and should be used primarily for human readability. Use rate in recording rules so that - increases are tracked consistently on a per-second basis. + increase should only be used with counters (for both floats and histograms). It is syntactic sugar + for rate(v) multiplied by the number of seconds under the specified time range window, and should + be used primarily for human readability. Use rate in recording rules so that increases are tracked + consistently on a per-second basis. +

        + + ), + info: ( + <> +

        + _The info function is an experiment to improve UX around including labels from{" "} + + info metrics + + . The behavior of this function may change in future versions of Prometheus, including its removal from PromQL.{" "} + info has to be enabled via the + feature flag{" "} + --enable-feature=promql-experimental-functions._ +

        + +

        + info(v instant-vector, [data-label-selector instant-vector]) finds, for each time series in{" "} + v, all info series with matching identifying labels (more on this later), and adds the + union of their data (i.e., non-identifying) labels to the time series. The second argument{" "} + data-label-selector is optional. It is not a real instant vector, but uses a subset of its syntax. + It must start and end with curly braces ( + + {"{"} ... {"}"} + + ) and may only contain label matchers. The label matchers are used to constrain which info series to consider + and which data labels to add to v. +

        + +

        + Identifying labels of an info series are the subset of labels that uniquely identify the info series. The + remaining labels are considered + data labels (also called non-identifying). (Note that Prometheus’s concept of time series + identity always includes all the labels. For the sake of the info + function, we “logically” define info series identity in a different way than in the conventional Prometheus + view.) The identifying labels of an info series are used to join it to regular (non-info) series, i.e. those + series that have the same labels as the identifying labels of the info series. The data labels, which are the + ones added to the regular series by the info function, effectively encode metadata key value pairs. + (This implies that a change in the data labels in the conventional Prometheus view constitutes the end of one + info series and the beginning of a new info series, while the “logical” view of the info function + is that the same info series continues to exist, just with different “data”.) +

        + +

        + The conventional approach of adding data labels is sometimes called a “join query”, as illustrated by the + following example: +

        + +
        +        
        +          {" "}
        +          rate(http_server_request_duration_seconds_count[2m]) * on (job, instance) group_left (k8s_cluster_name)
        +          target_info
        +        
        +      
        + +

        + The core of the query is the expression rate(http_server_request_duration_seconds_count[2m]). But + to add data labels from an info metric, the user has to use elaborate (and not very obvious) syntax to specify + which info metric to use (target_info), what the identifying labels are ( + on (job, instance)), and which data labels to add (group_left (k8s_cluster_name)). +

        + +

        + This query is not only verbose and hard to write, it might also run into an “identity crisis”: If any of the + data labels of target_info changes, Prometheus sees that as a change of series (as alluded to + above, Prometheus just has no native concept of non-identifying labels). If the old target_info{" "} + series is not properly marked as stale (which can happen with certain ingestion paths), the query above will + fail for up to 5m (the lookback delta) because it will find a conflicting match with both the old and the new + version of target_info. +

        + +

        + The info function not only resolves this conflict in favor of the newer series, it also simplifies + the syntax because it knows about the available info series and what their identifying labels are. The example + query looks like this with the info function: +

        + +
        +        
        +          info( rate(http_server_request_duration_seconds_count[2m]),
        +          {"{"}k8s_cluster_name=~".+"{"}"})
        +        
        +      
        + +

        + The common case of adding all data labels can be achieved by omitting the 2nd argument of the{" "} + info function entirely, simplifying the example even more: +

        + +
        +        info(rate(http_server_request_duration_seconds_count[2m]))
        +      
        + +

        + While info normally automatically finds all matching info series, it’s possible to restrict + them by providing a __name__ label matcher, e.g. + + {"{"}__name__="target_info"{"}"} + + . +

        + +

        + Note that if there are any time series in v that match the data-label-selector (or the + default target_info if that argument is not specified), they will be treated as info series and + will be returned unchanged. +

        + +

        Limitations

        + +

        + In its current iteration, info defaults to considering only info series with the name{" "} + target_info. It also assumes that the identifying info series labels are + instance and job. info does support other info series names however, + through + __name__ label matchers. E.g., one can explicitly say to consider both + target_info and build_info as follows: + + {"{"}__name__=~"(target|build)_info"{"}"} + + . However, the identifying labels always have to be instance and job. +

        + +

        + These limitations are partially defeating the purpose of the info function. At the current stage, + this is an experiment to find out how useful the approach turns out to be in practice. A final version of the{" "} + info function will indeed consider all matching info series and with their appropriate identifying + labels.

        ), irate: ( <>

        - irate(v range-vector) calculates the per-second instant rate of increase of the time series in the range - vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target - restarts) are automatically adjusted for. + irate(v range-vector) calculates the per-second instant rate of increase of the time series in the + range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to + target restarts) are automatically adjusted for. Both samples must be either float samples or histogram samples. + Elements in v where one of the last two samples is a float sample and the other is a histogram + sample will be omitted from the result vector, flagged by a warn-level annotation.

        - The following example expression returns the per-second rate of HTTP requests looking up to 5 minutes back for the - two most recent data points, per time series in the range vector: + irate should only be used with counters (for both floats and histograms). +

        + +

        + The following example expression returns the per-second rate of HTTP requests looking up to 5 minutes back for + the two most recent data points, per time series in the range vector:

                 
        -          irate(http_requests_total{'{'}job="api-server"{'}'}[5m])
        +          irate(http_requests_total{"{"}job="api-server"{"}"}[5m])
                 
               

        - irate should only be used when graphing volatile, fast-moving counters. Use rate for alerts - and slow-moving counters, as brief changes in the rate can reset the FOR clause and graphs consisting - entirely of rare spikes are hard to read. + irate should only be used when graphing volatile, fast-moving counters. Use rate for + alerts and slow-moving counters, as brief changes in the rate can reset the FOR clause and graphs + consisting entirely of rare spikes are hard to read.

        Note that when combining irate() with an aggregation operator (e.g. sum()) or a function - aggregating over time (any function ending in _over_time), always take a irate() first, - then aggregate. Otherwise irate() cannot detect counter resets when your target restarts. + aggregating over time (any function ending in _over_time), always take an irate(){" "} + first, then aggregate. Otherwise irate() cannot detect counter resets when your target restarts.

        ), label_join: ( <>

        - For each timeseries in v,{' '} + For each timeseries in v,{" "} label_join(v instant-vector, dst_label string, separator string, src_label_1 string, src_label_2 string, ...) - {' '} + {" "} joins all the values of all the src_labels - using separator and returns the timeseries with the label dst_label containing the joined - value. There can be any number of src_labels in this function. + using separator and returns the timeseries with the label dst_label containing the + joined value. There can be any number of src_labels in this function.

        @@ -1387,13 +1868,13 @@ const funcDocs: Record = {

        - This example will return a vector with each time series having a foo label with the value{' '} + This example will return a vector with each time series having a foo label with the value{" "} a,b,c added to it:

                 
        -          label_join(up{'{'}job="api-server",src1="a",src2="b",src3="c"{'}'},
        +          label_join(up{"{"}job="api-server",src1="a",src2="b",src3="c"{"}"},
                   "foo", ",", "src1", "src2", "src3")
                 
               
        @@ -1402,14 +1883,17 @@ const funcDocs: Record = { label_replace: ( <>

        - For each timeseries in v,{' '} - label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string) - matches the regular expression regex against the + For each timeseries in v,{" "} + + label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string) + + matches the regular expression regex against the value of the label src_label. If it matches, the value of the label dst_label in the returned timeseries will be the expansion of replacement, together with the original labels in the - input. Capturing groups in the regular expression can be referenced with $1, $2, etc. Named - capturing groups in the regular expression can be referenced with $name (where name is the - capturing group name). If the regular expression doesn’t match then the timeseries is returned unchanged. + input. Capturing groups in the regular expression can be referenced with $1, $2, etc. + Named capturing groups in the regular expression can be referenced with $name (where{" "} + name is the capturing group name). If the regular expression doesn’t match then the + timeseries is returned unchanged.

        @@ -1417,23 +1901,25 @@ const funcDocs: Record = {

        - This example will return timeseries with the values a:c at label service and a{' '} - at label foo: + This example will return timeseries with the values a:c at label service and{" "} + a at label foo:

                 
        -          label_replace(up{'{'}job="api-server",service="a:c"{'}'}, "foo", "$1",
        +          label_replace(up{"{"}job="api-server",service="a:c"{"}"}, "foo", "$1",
                   "service", "(.*):.*")
                 
               
        -

        This second example has the same effect than the first example, and illustrates use of named capturing groups:

        +

        + This second example has the same effect than the first example, and illustrates use of named capturing groups: +

                 
        -          label_replace(up{'{'}job="api-server",service="a:c"{'}'}, "foo", "$name",
        -          "service", "(?P<name>.*):(?P<version>.*)")
        +          label_replace(up{"{"}job="api-server",service="a:c"{"}"}, "foo",
        +          "$name", "service", "(?P<name>.*):(?P<version>.*)")
                 
               
        @@ -1441,40 +1927,42 @@ const funcDocs: Record = { last_over_time: ( <>

        - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

        • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
        • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
        • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
        • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
        • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
        • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
        • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
        • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
        • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
        • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -1483,32 +1971,74 @@ const funcDocs: Record = {

          If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

          • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
          • +
          • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
          • +
          • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
          • +
          • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
          • +
          • + first_over_time(range-vector): the oldest sample in the specified interval. +
          • +
          • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

          - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

          +

          These functions act on histograms in the following way:

          + +
            +
          • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
          • +
          • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
          • +
          • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
          • +
          +

          - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

          ), ln: ( <>

          - ln(v instant-vector) calculates the natural logarithm for all elements in v. Special cases - are: + ln(v instant-vector) calculates the natural logarithm for all float samples in v. + Histogram samples in the input vector are ignored silently. Special cases are:

            @@ -1530,56 +2060,60 @@ const funcDocs: Record = { log10: ( <>

            - log10(v instant-vector) calculates the decimal logarithm for all elements in v. The special - cases are equivalent to those in ln. + log10(v instant-vector) calculates the decimal logarithm for all float samples in v. + Histogram samples in the input vector are ignored silently. The special cases are equivalent to those in{" "} + ln.

            ), log2: ( <>

            - log2(v instant-vector) calculates the binary logarithm for all elements in v. The special - cases are equivalent to those in ln. + log2(v instant-vector) calculates the binary logarithm for all float samples in v. + Histogram samples in the input vector are ignored silently. The special cases are equivalent to those in{" "} + ln.

            ), mad_over_time: ( <>

            - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

            • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
            • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
            • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
            • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
            • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
            • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
            • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
            • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
            • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
            • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -1588,64 +2122,108 @@ const funcDocs: Record = {

              If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

              • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
              • +
              • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
              • +
              • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
              • +
              • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
              • +
              • + first_over_time(range-vector): the oldest sample in the specified interval. +
              • +
              • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

              - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

              +

              These functions act on histograms in the following way:

              + +
                +
              • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
              • +
              • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
              • +
              • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
              • +
              +

              - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

              ), max_over_time: ( <>

              - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

              • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
              • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
              • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
              • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
              • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
              • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
              • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
              • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
              • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
              • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -1654,64 +2232,108 @@ const funcDocs: Record = {

                If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

                • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                • +
                • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                • +
                • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                • +
                • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                • +
                • + first_over_time(range-vector): the oldest sample in the specified interval. +
                • +
                • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

                - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

                +

                These functions act on histograms in the following way:

                + +
                  +
                • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                • +
                • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                • +
                • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                • +
                +

                - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                ), min_over_time: ( <>

                - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

                • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
                • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
                • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
                • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
                • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
                • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
                • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
                • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
                • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
                • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -1720,95 +2342,140 @@ const funcDocs: Record = {

                  If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

                  • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                  • +
                  • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                  • +
                  • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                  • +
                  • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                  • +
                  • + first_over_time(range-vector): the oldest sample in the specified interval. +
                  • +
                  • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

                  - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

                  +

                  These functions act on histograms in the following way:

                  + +
                    +
                  • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                  • +
                  • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                  • +
                  • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                  • +
                  +

                  - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                  ), minute: ( <>

                  - minute(v=vector(time()) instant-vector) returns the minute of the hour for each of the given times in - UTC. Returned values are from 0 to 59. + minute(v=vector(time()) instant-vector) interprets float samples in v as timestamps + (number of seconds since January 1, 1970 UTC) and returns the minute of the hour (in UTC) for each of those + timestamps. Returned values are from 0 to 59. Histogram samples in the input vector are ignored silently.

                  ), month: ( <>

                  - month(v=vector(time()) instant-vector) returns the month of the year for each of the given times in UTC. - Returned values are from 1 to 12, where 1 means January etc. + month(v=vector(time()) instant-vector) interprets float samples in v as timestamps + (number of seconds since January 1, 1970 UTC) and returns the month of the year (in UTC) for each of those + timestamps. Returned values are from 1 to 12, where 1 means January etc. Histogram samples in the input vector + are ignored silently.

                  ), pi: ( <> -

                  The trigonometric functions work in radians:

                  +

                  The trigonometric functions work in radians. They ignore histogram samples in the input vector.

                  • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
                  • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
                  • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
                  • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
                  • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
                  • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
                  • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
                  • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
                  • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
                  • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
                  • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
                  • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
                  @@ -1816,13 +2483,13 @@ const funcDocs: Record = {
                  • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
                  • pi(): returns pi.
                  • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
                  @@ -1831,54 +2498,58 @@ const funcDocs: Record = { <>

                  predict_linear(v range-vector, t scalar) predicts the value of time series - t seconds from now, based on the range vector v, using{' '} - simple linear regression. The range vector must - have at least two samples in order to perform the calculation. When +Inf or -Inf are found - in the range vector, the slope and offset value calculated will be NaN. + t seconds from now, based on the range vector v, using{" "} + simple linear regression. The range vector + must have at least two float samples in order to perform the calculation. When +Inf or{" "} + -Inf are found in the range vector, the predicted value will be NaN.

                  - predict_linear should only be used with gauges. + predict_linear should only be used with gauges and only works for float samples. Elements in the + range vector that contain only histogram samples are ignored entirely. For elements that contain a mix of float + and histogram samples, only the float samples are used as input, which is flagged by an info-level annotation.

                  ), present_over_time: ( <>

                  - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

                  • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
                  • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
                  • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
                  • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
                  • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
                  • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
                  • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
                  • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
                  • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
                  • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -1887,64 +2558,108 @@ const funcDocs: Record = {

                    If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

                    • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                    • +
                    • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                    • +
                    • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                    • +
                    • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                    • +
                    • + first_over_time(range-vector): the oldest sample in the specified interval. +
                    • +
                    • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

                    - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

                    +

                    These functions act on histograms in the following way:

                    + +
                      +
                    • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                    • +
                    • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                    • +
                    • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                    • +
                    +

                    - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                    ), quantile_over_time: ( <>

                    - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

                    • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
                    • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
                    • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
                    • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
                    • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
                    • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
                    • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
                    • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
                    • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
                    • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -1953,79 +2668,121 @@ const funcDocs: Record = {

                      If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

                      • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                      • +
                      • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                      • +
                      • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                      • +
                      • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                      • +
                      • + first_over_time(range-vector): the oldest sample in the specified interval. +
                      • +
                      • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

                      - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

                      +

                      These functions act on histograms in the following way:

                      + +
                        +
                      • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                      • +
                      • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                      • +
                      • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                      • +
                      +

                      - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                      ), rad: ( <> -

                      The trigonometric functions work in radians:

                      +

                      The trigonometric functions work in radians. They ignore histogram samples in the input vector.

                      • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
                      • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
                      • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
                      • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
                      • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
                      • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
                      • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
                      • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
                      • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
                      • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
                      • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
                      • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
                      @@ -2033,13 +2790,13 @@ const funcDocs: Record = {
                      • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
                      • pi(): returns pi.
                      • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
                      @@ -2047,40 +2804,40 @@ const funcDocs: Record = { rate: ( <>

                      - rate(v range-vector) calculates the per-second average rate of increase of the time series in the range - vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, - the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of - scrape cycles with the range’s time period. + rate(v range-vector) calculates the per-second average rate of increase of the time series in the + range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted + for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect + alignment of scrape cycles with the range’s time period.

                      - The following example expression returns the per-second rate of HTTP requests as measured over the last 5 minutes, + The following example expression returns the per-second average rate of HTTP requests over the last 5 minutes, per time series in the range vector:

                               
                      -          rate(http_requests_total{'{'}job="api-server"{'}'}[5m])
                      +          rate(http_requests_total{"{"}job="api-server"{"}"}[5m])
                               
                             

                      - rate acts on native histograms by calculating a new histogram where each component (sum and count of - observations, buckets) is the rate of increase between the respective component in the first and last native - histogram in - v. However, each element in v that contains a mix of float and native histogram samples - within the range, will be missing from the result vector. + rate acts on native histograms by calculating a new histogram where each component (sum and count + of observations, buckets) is the rate of increase between the respective component in the first and last native + histogram in v. However, each element in v that contains a mix of float and native + histogram samples within the range, will be omitted from the result vector, flagged by a warn-level annotation.

                      - rate should only be used with counters and native histograms where the components behave like counters. - It is best suited for alerting, and for graphing of slow-moving counters. + rate should only be used with counters (for both floats and histograms). It is best suited for + alerting, and for graphing of slow-moving counters.

                      - Note that when combining rate() with an aggregation operator (e.g. sum()) or a function - aggregating over time (any function ending in _over_time), always take a rate() first, then - aggregate. Otherwise rate() cannot detect counter resets when your target restarts. + Note that when combining rate() with an aggregation operator (e.g. sum()) or a + function aggregating over time (any function ending in _over_time), always take a{" "} + rate() first, then aggregate. Otherwise rate() cannot detect counter resets when your + target restarts.

                      ), @@ -2089,102 +2846,104 @@ const funcDocs: Record = {

                      For each input time series, resets(v range-vector) returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive float samples is - interpreted as a counter reset. A reset in a native histogram is detected in a more complex way: Any decrease in any - bucket, including the zero bucket, or in the count of observation constitutes a counter reset, but also the - disappearance of any previously populated bucket, an increase in bucket resolution, or a decrease of the zero-bucket - width. + interpreted as a counter reset. A reset in a native histogram is detected in a more complex way: Any decrease in + any bucket, including the zero bucket, or in the count of observation constitutes a counter reset, but also the + disappearance of any previously populated bucket, a decrease of the zero-bucket width, or any schema change that + is not a compatible decrease of resolution.

                      - resets should only be used with counters and counter-like native histograms. + resets should only be used with counters (for both floats and histograms).

                      - If the range vector contains a mix of float and histogram samples for the same series, counter resets are detected - separately and their numbers added up. The change from a float to a histogram sample is not considered a - counter reset. Each float sample is compared to the next float sample, and each histogram is comprared to the next - histogram. + A float sample followed by a histogram sample, or vice versa, counts as a reset. A counter histogram sample + followed by a gauge histogram sample, or vice versa, also counts as a reset (but note that resets{" "} + should not be used on gauges in the first place, see above).

                      ), round: ( <>

                      - round(v instant-vector, to_nearest=1 scalar) rounds the sample values of all elements in v{' '} - to the nearest integer. Ties are resolved by rounding up. The optional to_nearest argument allows - specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction. + round(v instant-vector, to_nearest=1 scalar) rounds the sample values of all elements in{" "} + v to the nearest integer. Ties are resolved by rounding up. The optional to_nearest{" "} + argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may + also be a fraction. Histogram samples in the input vector are ignored silently.

                      ), scalar: ( <>

                      - Given a single-element input vector, scalar(v instant-vector) returns the sample value of that single - element as a scalar. If the input vector does not have exactly one element, scalar will return{' '} - NaN. + Given an input vector that contains only one element with a float sample, + scalar(v instant-vector) returns the sample value of that float sample as a scalar. If the input + vector does not have exactly one element with a float sample, scalar will return NaN. + Histogram samples in the input vector are ignored silently.

                      ), sgn: ( <>

                      - sgn(v instant-vector) returns a vector with all sample values converted to their sign, defined as this: - 1 if v is positive, -1 if v is negative and 0 if v is equal to zero. + sgn(v instant-vector) returns a vector with all float sample values converted to their sign, + defined as this: 1 if v is positive, -1 if v is negative and 0 if v is equal to zero. Histogram samples in the + input vector are ignored silently.

                      ), sin: ( <> -

                      The trigonometric functions work in radians:

                      +

                      The trigonometric functions work in radians. They ignore histogram samples in the input vector.

                      • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
                      • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
                      • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
                      • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
                      • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
                      • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
                      • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
                      • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
                      • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
                      • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
                      • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
                      • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
                      @@ -2192,69 +2951,69 @@ const funcDocs: Record = {
                      • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
                      • pi(): returns pi.
                      • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
                      ), sinh: ( <> -

                      The trigonometric functions work in radians:

                      +

                      The trigonometric functions work in radians. They ignore histogram samples in the input vector.

                      • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
                      • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
                      • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
                      • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
                      • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
                      • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
                      • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
                      • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
                      • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
                      • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
                      • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
                      • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
                      @@ -2262,13 +3021,13 @@ const funcDocs: Record = {
                      • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
                      • pi(): returns pi.
                      • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
                      @@ -2276,13 +3035,13 @@ const funcDocs: Record = { sort: ( <>

                      - sort(v instant-vector) returns vector elements sorted by their sample values, in ascending order. Native - histograms are sorted by their sum of observations. + sort(v instant-vector) returns vector elements sorted by their float sample values, in ascending + order. Histogram samples in the input vector are ignored silently.

                      - Please note that sort only affects the results of instant queries, as range query results always have a - fixed output ordering. + Please note that sort only affects the results of instant queries, as range query results always + have a fixed output ordering.

                      ), @@ -2290,24 +3049,27 @@ const funcDocs: Record = { <>

                      - This function has to be enabled via the{' '} - feature flag{' '} + This function has to be enabled via the{" "} + feature flag --enable-feature=promql-experimental-functions.

                      - sort_by_label(v instant-vector, label string, ...) returns vector elements sorted by the values of the - given labels in ascending order. In case these label values are equal, elements are sorted by their full label sets. + sort_by_label(v instant-vector, label string, ...) returns vector elements sorted by the values of + the given labels in ascending order. In case these label values are equal, elements are sorted by their full + label sets. + sort_by_label acts on float and histogram samples in the same way.

                      - Please note that the sort by label functions only affect the results of instant queries, as range query results + Please note that sort_by_label only affects the results of instant queries, as range query results always have a fixed output ordering.

                      - This function uses natural sort order. + sort_by_label uses{" "} + natural sort order.

                      ), @@ -2315,8 +3077,8 @@ const funcDocs: Record = { <>

                      - This function has to be enabled via the{' '} - feature flag{' '} + This function has to be enabled via the{" "} + feature flag --enable-feature=promql-experimental-functions.

                      @@ -2324,15 +3086,6 @@ const funcDocs: Record = {

                      Same as sort_by_label, but sorts in descending order.

                      - -

                      - Please note that the sort by label functions only affect the results of instant queries, as range query results - always have a fixed output ordering. -

                      - -

                      - This function uses natural sort order. -

                      ), sort_desc: ( @@ -2340,57 +3093,55 @@ const funcDocs: Record = {

                      Same as sort, but sorts in descending order.

                      - -

                      - Like sort, sort_desc only affects the results of instant queries, as range query results - always have a fixed output ordering. -

                      ), sqrt: ( <>

                      - sqrt(v instant-vector) calculates the square root of all elements in v. + sqrt(v instant-vector) calculates the square root of all float samples in + v. Histogram samples in the input vector are ignored silently.

                      ), stddev_over_time: ( <>

                      - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

                      • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
                      • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
                      • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
                      • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
                      • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
                      • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
                      • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
                      • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
                      • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
                      • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -2399,64 +3150,108 @@ const funcDocs: Record = {

                        If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

                        • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                        • +
                        • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                        • +
                        • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                        • +
                        • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                        • +
                        • + first_over_time(range-vector): the oldest sample in the specified interval. +
                        • +
                        • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

                        - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

                        +

                        These functions act on histograms in the following way:

                        + +
                          +
                        • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                        • +
                        • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                        • +
                        • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                        • +
                        +

                        - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                        ), stdvar_over_time: ( <>

                        - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

                        • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
                        • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
                        • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
                        • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
                        • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
                        • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
                        • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
                        • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
                        • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
                        • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -2465,64 +3260,108 @@ const funcDocs: Record = {

                          If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

                          • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                          • +
                          • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                          • +
                          • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                          • +
                          • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                          • +
                          • + first_over_time(range-vector): the oldest sample in the specified interval. +
                          • +
                          • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

                          - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

                          +

                          These functions act on histograms in the following way:

                          + +
                            +
                          • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                          • +
                          • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                          • +
                          • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                          • +
                          +

                          - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                          ), sum_over_time: ( <>

                          - The following functions allow aggregating each series of a given range vector over time and return an instant vector - with per-series aggregation results: + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results:

                          • - avg_over_time(range-vector): the average value of all points in the specified interval. + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below).
                          • - min_over_time(range-vector): the minimum value of all points in the specified interval. + min_over_time(range-vector): the minimum value of all float samples in the specified interval.
                          • - max_over_time(range-vector): the maximum value of all points in the specified interval. + max_over_time(range-vector): the maximum value of all float samples in the specified interval.
                          • - sum_over_time(range-vector): the sum of all values in the specified interval. + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below).
                          • - count_over_time(range-vector): the count of all values in the specified interval. + count_over_time(range-vector): the count of all samples in the specified interval.
                          • - quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of the values in the specified - interval. + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval.
                          • - stddev_over_time(range-vector): the population standard deviation of the values in the specified - interval. + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval.
                          • - stdvar_over_time(range-vector): the population standard variance of the values in the specified - interval. + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval.
                          • - last_over_time(range-vector): the most recent point value in the specified interval. + last_over_time(range-vector): the most recent sample in the specified interval.
                          • present_over_time(range-vector): the value 1 for any series in the specified interval. @@ -2531,79 +3370,121 @@ const funcDocs: Record = {

                            If the feature flag - --enable-feature=promql-experimental-functions is set, the following additional functions are available: + --enable-feature=promql-experimental-functions is set, the following additional functions are + available:

                            • - mad_over_time(range-vector): the median absolute deviation of all points in the specified interval. + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                            • +
                            • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                            • +
                            • + first_over_time(range-vector): the oldest sample in the specified interval. +
                            • +
                            • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval.

                            - Note that all values in the specified interval have the same weight in the aggregation even if the values are not - equally spaced throughout the interval. + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval.

                            +

                            These functions act on histograms in the following way:

                            + +
                              +
                            • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                            • +
                            • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                            • +
                            • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                            • +
                            +

                            - avg_over_time, sum_over_time, count_over_time, last_over_time, - and - present_over_time handle native histograms as expected. All other functions ignore histogram samples. + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                            ), tan: ( <> -

                            The trigonometric functions work in radians:

                            +

                            The trigonometric functions work in radians. They ignore histogram samples in the input vector.

                            • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
                            • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
                            • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
                            • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
                            • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
                            • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
                            • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
                            • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
                            • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
                            • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
                            • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
                            • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
                            @@ -2611,69 +3492,69 @@ const funcDocs: Record = {
                            • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
                            • pi(): returns pi.
                            • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
                            ), tanh: ( <> -

                            The trigonometric functions work in radians:

                            +

                            The trigonometric functions work in radians. They ignore histogram samples in the input vector.

                            • - acos(v instant-vector): calculates the arccosine of all elements in v ( + acos(v instant-vector): calculates the arccosine of all float samples in v ( special cases).
                            • - acosh(v instant-vector): calculates the inverse hyperbolic cosine of all elements in v ( - special cases). + acosh(v instant-vector): calculates the inverse hyperbolic cosine of all float samples in{" "} + v (special cases).
                            • - asin(v instant-vector): calculates the arcsine of all elements in v ( + asin(v instant-vector): calculates the arcsine of all float samples in v ( special cases).
                            • - asinh(v instant-vector): calculates the inverse hyperbolic sine of all elements in v ( - special cases). + asinh(v instant-vector): calculates the inverse hyperbolic sine of all float samples in{" "} + v (special cases).
                            • - atan(v instant-vector): calculates the arctangent of all elements in v ( + atan(v instant-vector): calculates the arctangent of all float samples in v ( special cases).
                            • - atanh(v instant-vector): calculates the inverse hyperbolic tangent of all elements in v ( - special cases). + atanh(v instant-vector): calculates the inverse hyperbolic tangent of all float samples in{" "} + v (special cases).
                            • - cos(v instant-vector): calculates the cosine of all elements in v ( + cos(v instant-vector): calculates the cosine of all float samples in v ( special cases).
                            • - cosh(v instant-vector): calculates the hyperbolic cosine of all elements in v ( + cosh(v instant-vector): calculates the hyperbolic cosine of all float samples in v ( special cases).
                            • - sin(v instant-vector): calculates the sine of all elements in v ( + sin(v instant-vector): calculates the sine of all float samples in v ( special cases).
                            • - sinh(v instant-vector): calculates the hyperbolic sine of all elements in v ( + sinh(v instant-vector): calculates the hyperbolic sine of all float samples in v ( special cases).
                            • - tan(v instant-vector): calculates the tangent of all elements in v ( + tan(v instant-vector): calculates the tangent of all float samples in v ( special cases).
                            • - tanh(v instant-vector): calculates the hyperbolic tangent of all elements in v ( - special cases). + tanh(v instant-vector): calculates the hyperbolic tangent of all float samples in v{" "} + (special cases).
                            @@ -2681,13 +3562,13 @@ const funcDocs: Record = {
                            • - deg(v instant-vector): converts radians to degrees for all elements in v. + deg(v instant-vector): converts radians to degrees for all float samples in v.
                            • pi(): returns pi.
                            • - rad(v instant-vector): converts degrees to radians for all elements in v. + rad(v instant-vector): converts degrees to radians for all float samples in v.
                            @@ -2695,8 +3576,8 @@ const funcDocs: Record = { time: ( <>

                            - time() returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return - the current time, but the time at which the expression is to be evaluated. + time() returns the number of seconds since January 1, 1970 UTC. Note that this does not actually + return the current time, but the time at which the expression is to be evaluated.

                            ), @@ -2704,14 +3585,455 @@ const funcDocs: Record = { <>

                            timestamp(v instant-vector) returns the timestamp of each of the samples of the given vector as the - number of seconds since January 1, 1970 UTC. It also works with histogram samples. + number of seconds since January 1, 1970 UTC. It acts on float and histogram samples in the same way. +

                            + + ), + ts_of_first_over_time: ( + <> +

                            + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results: +

                            + +
                              +
                            • + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below). +
                            • +
                            • + min_over_time(range-vector): the minimum value of all float samples in the specified interval. +
                            • +
                            • + max_over_time(range-vector): the maximum value of all float samples in the specified interval. +
                            • +
                            • + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below). +
                            • +
                            • + count_over_time(range-vector): the count of all samples in the specified interval. +
                            • +
                            • + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval. +
                            • +
                            • + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval. +
                            • +
                            • + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval. +
                            • +
                            • + last_over_time(range-vector): the most recent sample in the specified interval. +
                            • +
                            • + present_over_time(range-vector): the value 1 for any series in the specified interval. +
                            • +
                            + +

                            + If the feature flag + --enable-feature=promql-experimental-functions is set, the following additional functions are + available: +

                            + +
                              +
                            • + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                            • +
                            • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                            • +
                            • + first_over_time(range-vector): the oldest sample in the specified interval. +
                            • +
                            • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval. +
                            • +
                            + +

                            + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval. +

                            + +

                            These functions act on histograms in the following way:

                            + +
                              +
                            • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                            • +
                            • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                            • +
                            • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                            • +
                            + +

                            + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step. +

                            + + ), + ts_of_last_over_time: ( + <> +

                            + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results: +

                            + +
                              +
                            • + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below). +
                            • +
                            • + min_over_time(range-vector): the minimum value of all float samples in the specified interval. +
                            • +
                            • + max_over_time(range-vector): the maximum value of all float samples in the specified interval. +
                            • +
                            • + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below). +
                            • +
                            • + count_over_time(range-vector): the count of all samples in the specified interval. +
                            • +
                            • + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval. +
                            • +
                            • + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval. +
                            • +
                            • + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval. +
                            • +
                            • + last_over_time(range-vector): the most recent sample in the specified interval. +
                            • +
                            • + present_over_time(range-vector): the value 1 for any series in the specified interval. +
                            • +
                            + +

                            + If the feature flag + --enable-feature=promql-experimental-functions is set, the following additional functions are + available: +

                            + +
                              +
                            • + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                            • +
                            • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                            • +
                            • + first_over_time(range-vector): the oldest sample in the specified interval. +
                            • +
                            • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval. +
                            • +
                            + +

                            + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval. +

                            + +

                            These functions act on histograms in the following way:

                            + +
                              +
                            • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                            • +
                            • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                            • +
                            • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                            • +
                            + +

                            + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step. +

                            + + ), + ts_of_max_over_time: ( + <> +

                            + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results: +

                            + +
                              +
                            • + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below). +
                            • +
                            • + min_over_time(range-vector): the minimum value of all float samples in the specified interval. +
                            • +
                            • + max_over_time(range-vector): the maximum value of all float samples in the specified interval. +
                            • +
                            • + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below). +
                            • +
                            • + count_over_time(range-vector): the count of all samples in the specified interval. +
                            • +
                            • + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval. +
                            • +
                            • + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval. +
                            • +
                            • + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval. +
                            • +
                            • + last_over_time(range-vector): the most recent sample in the specified interval. +
                            • +
                            • + present_over_time(range-vector): the value 1 for any series in the specified interval. +
                            • +
                            + +

                            + If the feature flag + --enable-feature=promql-experimental-functions is set, the following additional functions are + available: +

                            + +
                              +
                            • + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                            • +
                            • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                            • +
                            • + first_over_time(range-vector): the oldest sample in the specified interval. +
                            • +
                            • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval. +
                            • +
                            + +

                            + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval. +

                            + +

                            These functions act on histograms in the following way:

                            + +
                              +
                            • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                            • +
                            • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                            • +
                            • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                            • +
                            + +

                            + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step. +

                            + + ), + ts_of_min_over_time: ( + <> +

                            + The following functions allow aggregating each series of a given range vector over time and return an instant + vector with per-series aggregation results: +

                            + +
                              +
                            • + avg_over_time(range-vector): the average value of all float or histogram samples in the specified + interval (see details below). +
                            • +
                            • + min_over_time(range-vector): the minimum value of all float samples in the specified interval. +
                            • +
                            • + max_over_time(range-vector): the maximum value of all float samples in the specified interval. +
                            • +
                            • + sum_over_time(range-vector): the sum of all float or histogram samples in the specified interval + (see details below). +
                            • +
                            • + count_over_time(range-vector): the count of all samples in the specified interval. +
                            • +
                            • + quantile_over_time(scalar, range-vector): the φ-quantile (0 ≤ φ ≤ 1) of all float samples in the + specified interval. +
                            • +
                            • + stddev_over_time(range-vector): the population standard deviation of all float samples in the + specified interval. +
                            • +
                            • + stdvar_over_time(range-vector): the population standard variance of all float samples in the + specified interval. +
                            • +
                            • + last_over_time(range-vector): the most recent sample in the specified interval. +
                            • +
                            • + present_over_time(range-vector): the value 1 for any series in the specified interval. +
                            • +
                            + +

                            + If the feature flag + --enable-feature=promql-experimental-functions is set, the following additional functions are + available: +

                            + +
                              +
                            • + mad_over_time(range-vector): the median absolute deviation of all float samples in the specified + interval. +
                            • +
                            • + ts_of_min_over_time(range-vector): the timestamp of the last float sample that has the minimum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_max_over_time(range-vector): the timestamp of the last float sample that has the maximum + value of all float samples in the specified interval. +
                            • +
                            • + ts_of_last_over_time(range-vector): the timestamp of last sample in the specified interval. +
                            • +
                            • + first_over_time(range-vector): the oldest sample in the specified interval. +
                            • +
                            • + ts_of_first_over_time(range-vector): the timestamp of earliest sample in the specified interval. +
                            • +
                            + +

                            + Note that all values in the specified interval have the same weight in the aggregation even if the values are + not equally spaced throughout the interval. +

                            + +

                            These functions act on histograms in the following way:

                            + +
                              +
                            • + count_over_time, first_over_time, last_over_time, and + present_over_time() act on float and histogram samples in the same way. +
                            • +
                            • + avg_over_time() and sum_over_time() act on histogram samples in a way that + corresponds to the respective aggregation operators. If a series contains a mix of float samples and histogram + samples within the range, the corresponding result is removed entirely from the output vector. Such a removal + is flagged by a warn-level annotation. +
                            • +
                            • + All other functions ignore histogram samples in the following way: Input ranges containing only histogram + samples are silently removed from the output. For ranges with a mix of histogram and float samples, only the + float samples are processed and the omission of the histogram samples is flagged by an info-level annotation. +
                            • +
                            + +

                            + first_over_time(m[1m]) differs from m offset 1m in that the former will select the + first sample of m within the 1m range, where m offset 1m will select the most + recent sample within the lookback interval outside and prior to the 1m offset. This is particularly + useful with first_over_time(m[step()]) + in range queries (available when --enable-feature=promql-duration-expr is set) to ensure that the + sample selected is within the range step.

                            ), vector: ( <>

                            - vector(s scalar) returns the scalar s as a vector with no labels. + vector(s scalar) converts the scalar s to a float sample and returns it as a + single-element instant vector with no labels.

                            ), @@ -2719,6 +4041,7 @@ const funcDocs: Record = { <>

                            year(v=vector(time()) instant-vector) returns the year for each of the given times in UTC. + Histogram samples in the input vector are ignored silently.

                            ), diff --git a/web/ui/mantine-ui/src/promql/functionSignatures.ts b/web/ui/mantine-ui/src/promql/functionSignatures.ts index 472d54ac5a..837a271dce 100644 --- a/web/ui/mantine-ui/src/promql/functionSignatures.ts +++ b/web/ui/mantine-ui/src/promql/functionSignatures.ts @@ -1,140 +1,202 @@ -import { valueType, Func } from './ast'; +import { valueType, Func } from "./ast"; export const functionSignatures: Record = { - abs: { name: 'abs', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - absent: { name: 'absent', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - absent_over_time: { name: 'absent_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - acos: { name: 'acos', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - acosh: { name: 'acosh', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - asin: { name: 'asin', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - asinh: { name: 'asinh', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - atan: { name: 'atan', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - atanh: { name: 'atanh', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - avg_over_time: { name: 'avg_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - ceil: { name: 'ceil', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - changes: { name: 'changes', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + abs: { name: "abs", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + absent: { name: "absent", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + absent_over_time: { + name: "absent_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, + acos: { name: "acos", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + acosh: { name: "acosh", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + asin: { name: "asin", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + asinh: { name: "asinh", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + atan: { name: "atan", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + atanh: { name: "atanh", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + avg_over_time: { name: "avg_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + ceil: { name: "ceil", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + changes: { name: "changes", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, clamp: { - name: 'clamp', + name: "clamp", argTypes: [valueType.vector, valueType.scalar, valueType.scalar], variadic: 0, returnType: valueType.vector, }, clamp_max: { - name: 'clamp_max', + name: "clamp_max", argTypes: [valueType.vector, valueType.scalar], variadic: 0, returnType: valueType.vector, }, clamp_min: { - name: 'clamp_min', + name: "clamp_min", argTypes: [valueType.vector, valueType.scalar], variadic: 0, returnType: valueType.vector, }, - cos: { name: 'cos', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - cosh: { name: 'cosh', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - count_over_time: { name: 'count_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - day_of_month: { name: 'day_of_month', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, - day_of_week: { name: 'day_of_week', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, - day_of_year: { name: 'day_of_year', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, - days_in_month: { name: 'days_in_month', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, - deg: { name: 'deg', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - delta: { name: 'delta', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - deriv: { name: 'deriv', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - exp: { name: 'exp', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - floor: { name: 'floor', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - histogram_avg: { name: 'histogram_avg', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - histogram_count: { name: 'histogram_count', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + cos: { name: "cos", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + cosh: { name: "cosh", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + count_over_time: { name: "count_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + day_of_month: { name: "day_of_month", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + day_of_week: { name: "day_of_week", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + day_of_year: { name: "day_of_year", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + days_in_month: { name: "days_in_month", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + deg: { name: "deg", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + delta: { name: "delta", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + deriv: { name: "deriv", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + double_exponential_smoothing: { + name: "double_exponential_smoothing", + argTypes: [valueType.matrix, valueType.scalar, valueType.scalar], + variadic: 0, + returnType: valueType.vector, + }, + exp: { name: "exp", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + first_over_time: { name: "first_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + floor: { name: "floor", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + histogram_avg: { name: "histogram_avg", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + histogram_count: { name: "histogram_count", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, histogram_fraction: { - name: 'histogram_fraction', + name: "histogram_fraction", argTypes: [valueType.scalar, valueType.scalar, valueType.vector], variadic: 0, returnType: valueType.vector, }, histogram_quantile: { - name: 'histogram_quantile', + name: "histogram_quantile", argTypes: [valueType.scalar, valueType.vector], variadic: 0, returnType: valueType.vector, }, - histogram_stddev: { name: 'histogram_stddev', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - histogram_stdvar: { name: 'histogram_stdvar', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - histogram_sum: { name: 'histogram_sum', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - double_exponential_smoothing: { - name: 'double_exponential_smoothing', - argTypes: [valueType.matrix, valueType.scalar, valueType.scalar], + histogram_quantiles: { + name: "histogram_quantiles", + argTypes: [valueType.vector, valueType.string, valueType.scalar, valueType.scalar], + variadic: 9, + returnType: valueType.vector, + }, + histogram_stddev: { + name: "histogram_stddev", + argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector, }, - hour: { name: 'hour', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, - idelta: { name: 'idelta', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - increase: { name: 'increase', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - irate: { name: 'irate', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + histogram_stdvar: { + name: "histogram_stdvar", + argTypes: [valueType.vector], + variadic: 0, + returnType: valueType.vector, + }, + histogram_sum: { name: "histogram_sum", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + hour: { name: "hour", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + idelta: { name: "idelta", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + increase: { name: "increase", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + info: { name: "info", argTypes: [valueType.vector, valueType.vector], variadic: 1, returnType: valueType.vector }, + irate: { name: "irate", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, label_join: { - name: 'label_join', + name: "label_join", argTypes: [valueType.vector, valueType.string, valueType.string, valueType.string], variadic: -1, returnType: valueType.vector, }, label_replace: { - name: 'label_replace', + name: "label_replace", argTypes: [valueType.vector, valueType.string, valueType.string, valueType.string, valueType.string], variadic: 0, returnType: valueType.vector, }, - last_over_time: { name: 'last_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - ln: { name: 'ln', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - log10: { name: 'log10', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - log2: { name: 'log2', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - mad_over_time: { name: 'mad_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - max_over_time: { name: 'max_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - min_over_time: { name: 'min_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - minute: { name: 'minute', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, - month: { name: 'month', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, - pi: { name: 'pi', argTypes: [], variadic: 0, returnType: valueType.scalar }, + last_over_time: { name: "last_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + ln: { name: "ln", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + log10: { name: "log10", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + log2: { name: "log2", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + mad_over_time: { name: "mad_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + max_over_time: { name: "max_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + min_over_time: { name: "min_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + minute: { name: "minute", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + month: { name: "month", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + pi: { name: "pi", argTypes: [], variadic: 0, returnType: valueType.scalar }, predict_linear: { - name: 'predict_linear', + name: "predict_linear", argTypes: [valueType.matrix, valueType.scalar], variadic: 0, returnType: valueType.vector, }, - present_over_time: { name: 'present_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + present_over_time: { + name: "present_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, quantile_over_time: { - name: 'quantile_over_time', + name: "quantile_over_time", argTypes: [valueType.scalar, valueType.matrix], variadic: 0, returnType: valueType.vector, }, - rad: { name: 'rad', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - rate: { name: 'rate', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - resets: { name: 'resets', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - round: { name: 'round', argTypes: [valueType.vector, valueType.scalar], variadic: 1, returnType: valueType.vector }, - scalar: { name: 'scalar', argTypes: [valueType.vector], variadic: 0, returnType: valueType.scalar }, - sgn: { name: 'sgn', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - sin: { name: 'sin', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - sinh: { name: 'sinh', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - sort: { name: 'sort', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + rad: { name: "rad", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + rate: { name: "rate", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + resets: { name: "resets", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + round: { name: "round", argTypes: [valueType.vector, valueType.scalar], variadic: 1, returnType: valueType.vector }, + scalar: { name: "scalar", argTypes: [valueType.vector], variadic: 0, returnType: valueType.scalar }, + sgn: { name: "sgn", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + sin: { name: "sin", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + sinh: { name: "sinh", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + sort: { name: "sort", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, sort_by_label: { - name: 'sort_by_label', + name: "sort_by_label", argTypes: [valueType.vector, valueType.string], variadic: -1, returnType: valueType.vector, }, sort_by_label_desc: { - name: 'sort_by_label_desc', + name: "sort_by_label_desc", argTypes: [valueType.vector, valueType.string], variadic: -1, returnType: valueType.vector, }, - sort_desc: { name: 'sort_desc', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - sqrt: { name: 'sqrt', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - stddev_over_time: { name: 'stddev_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - stdvar_over_time: { name: 'stdvar_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - sum_over_time: { name: 'sum_over_time', argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, - tan: { name: 'tan', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - tanh: { name: 'tanh', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - time: { name: 'time', argTypes: [], variadic: 0, returnType: valueType.scalar }, - timestamp: { name: 'timestamp', argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, - vector: { name: 'vector', argTypes: [valueType.scalar], variadic: 0, returnType: valueType.vector }, - year: { name: 'year', argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, + sort_desc: { name: "sort_desc", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + sqrt: { name: "sqrt", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + stddev_over_time: { + name: "stddev_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, + stdvar_over_time: { + name: "stdvar_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, + sum_over_time: { name: "sum_over_time", argTypes: [valueType.matrix], variadic: 0, returnType: valueType.vector }, + tan: { name: "tan", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + tanh: { name: "tanh", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + time: { name: "time", argTypes: [], variadic: 0, returnType: valueType.scalar }, + timestamp: { name: "timestamp", argTypes: [valueType.vector], variadic: 0, returnType: valueType.vector }, + ts_of_first_over_time: { + name: "ts_of_first_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, + ts_of_last_over_time: { + name: "ts_of_last_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, + ts_of_max_over_time: { + name: "ts_of_max_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, + ts_of_min_over_time: { + name: "ts_of_min_over_time", + argTypes: [valueType.matrix], + variadic: 0, + returnType: valueType.vector, + }, + vector: { name: "vector", argTypes: [valueType.scalar], variadic: 0, returnType: valueType.vector }, + year: { name: "year", argTypes: [valueType.vector], variadic: 1, returnType: valueType.vector }, }; diff --git a/web/ui/mantine-ui/src/promql/serialize.ts b/web/ui/mantine-ui/src/promql/serialize.ts index bbccede708..50c32c49e4 100644 --- a/web/ui/mantine-ui/src/promql/serialize.ts +++ b/web/ui/mantine-ui/src/promql/serialize.ts @@ -135,12 +135,16 @@ const serializeNode = ( case nodeType.binaryExpr: { let matching = ""; let grouping = ""; + let fill = ""; const vm = node.matching; - if (vm !== null && (vm.labels.length > 0 || vm.on)) { - if (vm.on) { - matching = ` on(${labelNameList(vm.labels)})`; - } else { - matching = ` ignoring(${labelNameList(vm.labels)})`; + if (vm !== null) { + if ( + vm.labels.length > 0 || + vm.on || + vm.card === vectorMatchCardinality.manyToOne || + vm.card === vectorMatchCardinality.oneToMany + ) { + matching = ` ${vm.on ? "on" : "ignoring"}(${labelNameList(vm.labels)})`; } if ( @@ -149,11 +153,26 @@ const serializeNode = ( ) { grouping = ` group_${vm.card === vectorMatchCardinality.manyToOne ? "left" : "right"}(${labelNameList(vm.include)})`; } + + const lfill = vm.fillValues.lhs; + const rfill = vm.fillValues.rhs; + if (lfill !== null || rfill !== null) { + if (lfill === rfill) { + fill = ` fill(${lfill})`; + } else { + if (lfill !== null) { + fill += ` fill_left(${lfill})`; + } + if (rfill !== null) { + fill += ` fill_right(${rfill})`; + } + } + } } return `${serializeNode(maybeParenthesizeBinopChild(node.op, node.lhs), childIndent, pretty)}${childSeparator}${ind}${ node.op - }${node.bool ? " bool" : ""}${matching}${grouping}${childSeparator}${serializeNode( + }${node.bool ? " bool" : ""}${matching}${grouping}${fill}${childSeparator}${serializeNode( maybeParenthesizeBinopChild(node.op, node.rhs), childIndent, pretty diff --git a/web/ui/mantine-ui/src/promql/serializeAndFormat.test.ts b/web/ui/mantine-ui/src/promql/serializeAndFormat.test.ts index 62b10cd781..f9ff039882 100644 --- a/web/ui/mantine-ui/src/promql/serializeAndFormat.test.ts +++ b/web/ui/mantine-ui/src/promql/serializeAndFormat.test.ts @@ -192,8 +192,7 @@ describe("serializeNode and formatNode", () => { anchored: false, smoothed: false, }, - output: - '{label1="value1"}', + output: '{label1="value1"}', }, // Anchored and smoothed modifiers. @@ -659,6 +658,7 @@ describe("serializeNode and formatNode", () => { labels: [], on: false, include: [], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, @@ -678,6 +678,7 @@ describe("serializeNode and formatNode", () => { labels: [], on: true, include: [], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, @@ -697,6 +698,7 @@ describe("serializeNode and formatNode", () => { labels: ["label1", "label2"], on: true, include: [], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, @@ -716,12 +718,55 @@ describe("serializeNode and formatNode", () => { labels: ["label1", "label2"], on: false, include: [], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, output: "… + ignoring(label1, label2) …", prettyOutput: ` … + ignoring(label1, label2) + …`, + }, + { + // Empty ignoring() without group modifiers can be stripped away. + node: { + type: nodeType.binaryExpr, + op: binaryOperatorType.add, + lhs: { type: nodeType.placeholder, children: [] }, + rhs: { type: nodeType.placeholder, children: [] }, + matching: { + card: vectorMatchCardinality.oneToOne, + labels: [], + on: false, + include: [], + fillValues: { lhs: null, rhs: null }, + }, + bool: false, + }, + output: "… + …", + prettyOutput: ` … ++ + …`, + }, + { + // Empty ignoring() with group modifiers may not be stripped away. + node: { + type: nodeType.binaryExpr, + op: binaryOperatorType.add, + lhs: { type: nodeType.placeholder, children: [] }, + rhs: { type: nodeType.placeholder, children: [] }, + matching: { + card: vectorMatchCardinality.manyToOne, + labels: [], + on: false, + include: ["__name__"], + fillValues: { lhs: null, rhs: null }, + }, + bool: false, + }, + output: "… + ignoring() group_left(__name__) …", + prettyOutput: ` … ++ ignoring() group_left(__name__) …`, }, { @@ -735,6 +780,7 @@ describe("serializeNode and formatNode", () => { labels: ["label1", "label2"], on: true, include: [], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, @@ -754,6 +800,7 @@ describe("serializeNode and formatNode", () => { labels: ["label1", "label2"], on: true, include: ["label3"], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, @@ -773,6 +820,7 @@ describe("serializeNode and formatNode", () => { labels: ["label1", "label2"], on: true, include: [], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, @@ -792,6 +840,7 @@ describe("serializeNode and formatNode", () => { labels: ["label1", "label2"], on: true, include: ["label3"], + fillValues: { lhs: null, rhs: null }, }, bool: false, }, @@ -825,6 +874,7 @@ describe("serializeNode and formatNode", () => { labels: ["label1", "label2"], on: true, include: ["label3"], + fillValues: { lhs: null, rhs: null }, }, bool: true, }, @@ -872,6 +922,7 @@ describe("serializeNode and formatNode", () => { include: ["c", "ü"], labels: ["b", "ö"], on: true, + fillValues: { lhs: null, rhs: null }, }, op: binaryOperatorType.div, rhs: { @@ -909,6 +960,7 @@ describe("serializeNode and formatNode", () => { include: [], labels: ["e", "ö"], on: false, + fillValues: { lhs: null, rhs: null }, }, op: binaryOperatorType.add, rhs: { diff --git a/web/ui/mantine-ui/src/promql/tools/gen_functions_docs/main.go b/web/ui/mantine-ui/src/promql/tools/gen_functions_docs/main.go index 89545c1e5e..74e8ac0354 100644 --- a/web/ui/mantine-ui/src/promql/tools/gen_functions_docs/main.go +++ b/web/ui/mantine-ui/src/promql/tools/gen_functions_docs/main.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -18,7 +18,7 @@ import ( "fmt" "io" "log" - "net/http" + "os" "sort" "strings" @@ -26,20 +26,23 @@ import ( "github.com/russross/blackfriday/v2" ) -var funcDocsRe = regexp.MustCompile("^## `(.+)\\(\\)`\n$|^## (Trigonometric Functions)\n$") +var funcDocsRe = regexp.MustCompile("^## `([^)]+)\\(\\)` and `([^)]+)\\(\\)`\n$|^## `(.+)\\(\\)`\n$|^## (Trigonometric Functions)\n$") func main() { - resp, err := http.Get("https://raw.githubusercontent.com/prometheus/prometheus/master/docs/querying/functions.md") + // Read from local file instead of fetching from upstream. + if len(os.Args) < 2 { + log.Fatalln("Usage: gen_functions_docs ") + } + functionsPath := os.Args[1] + file, err := os.Open(functionsPath) if err != nil { - log.Fatalln("Failed to fetch function docs:", err) - } - if resp.StatusCode != 200 { - log.Fatalln("Bad status code while fetching function docs:", resp.Status) + log.Fatalln("Failed to open function docs:", err) } + defer file.Close() funcDocs := map[string]string{} - r := bufio.NewReader(resp.Body) + r := bufio.NewReader(file) currentFunc := "" currentDocs := "" @@ -58,6 +61,11 @@ func main() { "last_over_time", "present_over_time", "mad_over_time", + "first_over_time", + "ts_of_first_over_time", + "ts_of_last_over_time", + "ts_of_max_over_time", + "ts_of_min_over_time", } { funcDocs[fn] = currentDocs } @@ -81,6 +89,12 @@ func main() { } { funcDocs[fn] = currentDocs } + case "histogram_count_and_histogram_sum": + funcDocs["histogram_count"] = currentDocs + funcDocs["histogram_sum"] = currentDocs + case "histogram_stddev_and_histogram_stdvar": + funcDocs["histogram_stddev"] = currentDocs + funcDocs["histogram_stdvar"] = currentDocs default: funcDocs[currentFunc] = currentDocs } @@ -103,10 +117,16 @@ func main() { } currentDocs = "" - currentFunc = string(matches[1]) - if matches[2] != "" { - // This is the case for "## Trigonometric Functions" - currentFunc = matches[2] + if matches[1] != "" && matches[2] != "" { + // Combined functions: "## `function1()` and `function2()`" + // Store as "function1_and_function2" and handle in saveCurrent. + currentFunc = matches[1] + "_and_" + matches[2] + } else if matches[3] != "" { + // Single function: "## `function_name()`" + currentFunc = string(matches[3]) + } else if matches[4] != "" { + // Special section: "## Trigonometric Functions" + currentFunc = matches[4] } } else { currentDocs += line diff --git a/web/ui/mantine-ui/src/promql/tools/gen_functions_list/main.go b/web/ui/mantine-ui/src/promql/tools/gen_functions_list/main.go index f479b6d36a..6b77f368c8 100644 --- a/web/ui/mantine-ui/src/promql/tools/gen_functions_list/main.go +++ b/web/ui/mantine-ui/src/promql/tools/gen_functions_list/main.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -41,10 +41,10 @@ func main() { sort.Strings(fnNames) fmt.Println(`import { valueType, Func } from './ast'; - export const functionSignatures: Record = {`) +export const functionSignatures: Record = {`) for _, fnName := range fnNames { fn := parser.Functions[fnName] fmt.Printf(" %s: { name: '%s', argTypes: [%s], variadic: %d, returnType: %s },\n", fn.Name, fn.Name, formatValueTypes(fn.ArgTypes), fn.Variadic, formatValueType(fn.ReturnType)) } - fmt.Println("}") + fmt.Println("};") } diff --git a/web/ui/mantine-ui/src/promql/tools/go.mod b/web/ui/mantine-ui/src/promql/tools/go.mod index 6983cf4fe6..af604b1964 100644 --- a/web/ui/mantine-ui/src/promql/tools/go.mod +++ b/web/ui/mantine-ui/src/promql/tools/go.mod @@ -1,26 +1,41 @@ module github.com/prometheus/prometheus/web/ui/mantine-ui/src/promql/tools -go 1.24.0 +go 1.25.5 require ( - github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc - github.com/prometheus/prometheus v0.54.1 + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 + github.com/prometheus/prometheus v0.309.1 github.com/russross/blackfriday/v2 v2.1.0 ) require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dennwc/varint v1.0.0 // indirect - github.com/go-kit/log v0.2.1 // indirect - github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_golang v1.19.1 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_golang/exp v0.0.0-20260108101519-fb0838f53562 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/sigv4 v0.4.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/api v0.266.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/protobuf v1.36.11 // indirect + k8s.io/client-go v0.35.0 // indirect ) + +replace cloud.google.com/go => cloud.google.com/go v0.123.0 diff --git a/web/ui/mantine-ui/src/promql/tools/go.sum b/web/ui/mantine-ui/src/promql/tools/go.sum index e7ed7cec79..6f43b2da98 100644 --- a/web/ui/mantine-ui/src/promql/tools/go.sum +++ b/web/ui/mantine-ui/src/promql/tools/go.sum @@ -1,47 +1,88 @@ -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0 h1:GJHeeA2N7xrG3q30L2UXDyuWRzDM900/65j70wcM4Ww= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/alecthomas/units v0.0.0-20240626203959-61d1e3462e30 h1:t3eaIm0rUkzbrIewtiFmMK5RXHej2XnoXNhxVsAYUfg= -github.com/alecthomas/units v0.0.0-20240626203959-61d1e3462e30/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= -github.com/aws/aws-sdk-go v1.54.19 h1:tyWV+07jagrNiCcGRzRhdtVjQs7Vy41NwsuOcl0IbVI= -github.com/aws/aws-sdk-go v1.54.19/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps= github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE= github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA= -github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= -github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= -github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= -github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -49,59 +90,85 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= -github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/prometheus/prometheus v0.54.1 h1:vKuwQNjnYN2/mDoWfHXDhAsz/68q/dQDb+YbcEqU7MQ= -github.com/prometheus/prometheus v0.54.1/go.mod h1:xlLByHhk2g3ycakQGrMaU8K7OySZx98BzeCR99991NY= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_golang/exp v0.0.0-20260108101519-fb0838f53562 h1:vwqZvuobg82U0gcG2eVrFH27806bUbNr32SvfRbvdsg= +github.com/prometheus/client_golang/exp v0.0.0-20260108101519-fb0838f53562/go.mod h1:PmAYDB13uBFBG9qE1qxZZgZWhg7Rg6SfKM5DMK7hjyI= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/prometheus v0.309.1 h1:jutK6eCYDpWdPTUbVbkcQsNCMO9CCkSwjQRMLds4jSo= +github.com/prometheus/prometheus v0.309.1/go.mod h1:d+dOGiVhuNDa4MaFXHVdnUBy/CzqlcNTooR8oM1wdTU= +github.com/prometheus/sigv4 v0.4.1 h1:EIc3j+8NBea9u1iV6O5ZAN8uvPq2xOIUPcqCTivHuXs= +github.com/prometheus/sigv4 v0.4.1/go.mod h1:eu+ZbRvsc5TPiHwqh77OWuCnWK73IdkETYY46P4dXOU= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= -golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -go.yaml.in/yaml/v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -go.yaml.in/yaml/v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= -k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= -k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= -k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= -k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/web/ui/mantine-ui/src/state/queryPageSlice.ts b/web/ui/mantine-ui/src/state/queryPageSlice.ts index 253b3ee9c6..7a4f7b257a 100644 --- a/web/ui/mantine-ui/src/state/queryPageSlice.ts +++ b/web/ui/mantine-ui/src/state/queryPageSlice.ts @@ -58,6 +58,7 @@ export interface Visualizer { resolution: GraphResolution; displayMode: GraphDisplayMode; showExemplars: boolean; + yAxisMin: number | null; } export type Panel = { @@ -86,6 +87,7 @@ export const newDefaultPanel = (): Panel => ({ resolution: { type: "auto", density: "medium" }, displayMode: GraphDisplayMode.Lines, showExemplars: false, + yAxisMin: null, }, }); @@ -113,6 +115,19 @@ export const queryPageSlice = createSlice({ state.panels.push(newDefaultPanel()); updateURL(state.panels); }, + duplicatePanel: ( + state, + { payload }: PayloadAction<{ idx: number; expr: string }> + ) => { + const newPanel = { + ...state.panels[payload.idx], + id: randomId(), + expr: payload.expr, + }; + // Insert the duplicated panel just below the original panel. + state.panels.splice(payload.idx + 1, 0, newPanel); + updateURL(state.panels); + }, removePanel: (state, { payload }: PayloadAction) => { state.panels.splice(payload, 1); updateURL(state.panels); @@ -151,6 +166,7 @@ export const { setPanels, addPanel, removePanel, + duplicatePanel, setExpr, addQueryToHistory, setShowTree, diff --git a/web/ui/mantine-ui/src/state/settingsSlice.ts b/web/ui/mantine-ui/src/state/settingsSlice.ts index 8b4a33bf76..a3e133380a 100644 --- a/web/ui/mantine-ui/src/state/settingsSlice.ts +++ b/web/ui/mantine-ui/src/state/settingsSlice.ts @@ -102,7 +102,7 @@ export const initialState: Settings = { ), showAnnotations: initializeFromLocalStorage( localStorageKeyShowAnnotations, - true + false ), showQueryWarnings: initializeFromLocalStorage( localStorageKeyShowQueryWarnings, diff --git a/web/ui/module/codemirror-promql/package.json b/web/ui/module/codemirror-promql/package.json index f40dc65432..5208513eab 100644 --- a/web/ui/module/codemirror-promql/package.json +++ b/web/ui/module/codemirror-promql/package.json @@ -1,6 +1,6 @@ { "name": "@prometheus-io/codemirror-promql", - "version": "0.307.3", + "version": "0.309.1", "description": "a CodeMirror mode for the PromQL language", "types": "dist/esm/index.d.ts", "module": "dist/esm/index.js", @@ -29,19 +29,19 @@ }, "homepage": "https://github.com/prometheus/prometheus/blob/main/web/ui/module/codemirror-promql/README.md", "dependencies": { - "@prometheus-io/lezer-promql": "0.307.3", - "lru-cache": "^11.2.2" + "@prometheus-io/lezer-promql": "0.309.1", + "lru-cache": "^11.2.5" }, "devDependencies": { - "@codemirror/autocomplete": "^6.19.0", - "@codemirror/language": "^6.11.3", - "@codemirror/lint": "^6.8.5", - "@codemirror/state": "^6.5.2", - "@codemirror/view": "^6.38.4", - "@lezer/common": "^1.2.3", - "@lezer/highlight": "^1.2.1", - "@lezer/lr": "^1.4.2", - "eslint-plugin-prettier": "^5.5.4", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/language": "^6.12.1", + "@codemirror/lint": "^6.9.3", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.12", + "@lezer/common": "^1.5.1", + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.8", + "eslint-plugin-prettier": "^5.5.5", "isomorphic-fetch": "^3.0.0", "nock": "^14.0.10" }, diff --git a/web/ui/module/codemirror-promql/src/client/prometheus.test.ts b/web/ui/module/codemirror-promql/src/client/prometheus.test.ts new file mode 100644 index 0000000000..c872edbb69 --- /dev/null +++ b/web/ui/module/codemirror-promql/src/client/prometheus.test.ts @@ -0,0 +1,97 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { HTTPPrometheusClient, CachedPrometheusClient } from './prometheus'; + +describe('HTTPPrometheusClient destroy', () => { + it('should be safe to call destroy multiple times', () => { + const client = new HTTPPrometheusClient({ url: 'http://localhost:8080' }); + // First call + client.destroy(); + // Second call should not throw + expect(() => client.destroy()).not.toThrow(); + }); + + it('should abort in-flight requests when destroy is called', async () => { + let abortSignal: AbortSignal | null | undefined; + + const mockFetch = (_url: RequestInfo, init?: RequestInit): Promise => { + abortSignal = init?.signal; + // Return a promise that never resolves to simulate an in-flight request + return new Promise(() => {}); + }; + + const client = new HTTPPrometheusClient({ + url: 'http://localhost:8080', + fetchFn: mockFetch, + }); + + // Start a request (don't await it) + client.labelNames(); + + // Verify the signal was captured and not aborted yet + expect(abortSignal).toBeDefined(); + expect(abortSignal?.aborted).toBe(false); + + // Destroy the client + client.destroy(); + + // Verify the request was aborted + expect(abortSignal?.aborted).toBe(true); + }); +}); + +describe('CachedPrometheusClient destroy', () => { + it('should be safe to call destroy multiple times', () => { + const httpClient = new HTTPPrometheusClient({ url: 'http://localhost:8080' }); + const cachedClient = new CachedPrometheusClient(httpClient); + + // First call + cachedClient.destroy(); + // Second call should not throw + expect(() => cachedClient.destroy()).not.toThrow(); + }); + + it('should call destroy on the underlying HTTPPrometheusClient', () => { + const httpClient = new HTTPPrometheusClient({ url: 'http://localhost:8080' }); + + let destroyCalled = false; + const originalDestroy = httpClient.destroy.bind(httpClient); + httpClient.destroy = () => { + destroyCalled = true; + originalDestroy(); + }; + + const cachedClient = new CachedPrometheusClient(httpClient); + cachedClient.destroy(); + + expect(destroyCalled).toBe(true); + }); + + it('should handle underlying clients without destroy method', () => { + // Create a minimal PrometheusClient without destroy + const minimalClient = { + labelNames: () => Promise.resolve([]), + labelValues: () => Promise.resolve([]), + metricMetadata: () => Promise.resolve({}), + series: () => Promise.resolve([]), + metricNames: () => Promise.resolve([]), + flags: () => Promise.resolve({}), + }; + + const cachedClient = new CachedPrometheusClient(minimalClient); + + // Should not throw even though underlying client has no destroy + expect(() => cachedClient.destroy()).not.toThrow(); + }); +}); diff --git a/web/ui/module/codemirror-promql/src/client/prometheus.ts b/web/ui/module/codemirror-promql/src/client/prometheus.ts index 165549ac82..91de148f3c 100644 --- a/web/ui/module/codemirror-promql/src/client/prometheus.ts +++ b/web/ui/module/codemirror-promql/src/client/prometheus.ts @@ -39,6 +39,9 @@ export interface PrometheusClient { // flags returns flag values that prometheus was configured with. flags(): Promise>; + + // destroy is called to release all resources held by this client + destroy?(): void; } export interface CacheConfig { @@ -88,6 +91,7 @@ export class HTTPPrometheusClient implements PrometheusClient { // when calling it, thus the indirection via another function wrapper. private readonly fetchFn: FetchFn = (input: RequestInfo, init?: RequestInit): Promise => fetch(input, init); private requestHeaders: Headers = new Headers(); + private readonly abortControllers: Set = new Set(); constructor(config: PrometheusConfig) { this.url = config.url ? config.url : ''; @@ -199,11 +203,22 @@ export class HTTPPrometheusClient implements PrometheusClient { }); } + destroy(): void { + for (const controller of this.abortControllers) { + controller.abort(); + } + this.abortControllers.clear(); + } + private fetchAPI(resource: string, init?: RequestInit): Promise { + const controller = new AbortController(); + this.abortControllers.add(controller); + if (init) { init.headers = this.requestHeaders; + init.signal = controller.signal; } else { - init = { headers: this.requestHeaders }; + init = { headers: this.requestHeaders, signal: controller.signal }; } return this.fetchFn(this.url + resource, init) .then((res) => { @@ -221,6 +236,9 @@ export class HTTPPrometheusClient implements PrometheusClient { throw new Error('missing "data" field in response JSON'); } return apiRes.data; + }) + .finally(() => { + this.abortControllers.delete(controller); }); } @@ -448,4 +466,8 @@ export class CachedPrometheusClient implements PrometheusClient { return flags; }); } + + destroy(): void { + this.client.destroy?.(); + } } diff --git a/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts b/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts index 587b31e743..facda35ac8 100644 --- a/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts +++ b/web/ui/module/codemirror-promql/src/complete/hybrid.test.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { analyzeCompletion, computeStartCompletePosition, ContextKind } from './hybrid'; +import { analyzeCompletion, computeStartCompletePosition, computeEndCompletePosition, ContextKind, durationWithUnitRegexp } from './hybrid'; import { createEditorState, mockedMetricsTerms, mockPrometheusServer } from '../test/utils-test'; import { Completion, CompletionContext } from '@codemirror/autocomplete'; import { @@ -29,6 +29,7 @@ import { import { EqlSingle, Neq } from '@prometheus-io/lezer-promql'; import { syntaxTree } from '@codemirror/language'; import { newCompleteStrategy } from './index'; +import nock from 'nock'; describe('analyzeCompletion test', () => { const testCases = [ @@ -299,6 +300,12 @@ describe('analyzeCompletion test', () => { pos: 33, // cursor is between the bracket after the comma expectedContext: [{ kind: ContextKind.LabelName, metricName: 'metric_name' }], }, + { + title: 'no label suggestions after closing matcher', + expr: 'up{job="prometheus"}', + pos: 20, // cursor is right after the closing curly bracket + expectedContext: [], + }, { title: 'continue autocomplete labelName that defined a metric', expr: '{myL}', @@ -553,6 +560,18 @@ describe('analyzeCompletion test', () => { pos: 28, expectedContext: [{ kind: ContextKind.Duration }], }, + { + title: 'do not autocomplete duration when unit already present in matrixSelector', + expr: 'rate(foo[5m])', + pos: 10, + expectedContext: [], + }, + { + title: 'do not autocomplete duration when multi char unit already present in matrixSelector', + expr: 'rate(foo[5ms])', + pos: 10, + expectedContext: [], + }, { title: 'autocomplete duration for a subQuery', expr: 'go[5d:5]', @@ -619,7 +638,43 @@ describe('analyzeCompletion test', () => { const state = createEditorState(value.expr); const node = syntaxTree(state).resolve(value.pos, -1); const result = analyzeCompletion(state, node, value.pos); - expect(value.expectedContext).toEqual(result); + expect(result).toEqual(value.expectedContext); + }); + }); +}); + +describe('durationWithUnitRegexp test', () => { + it('should match complete durations with units', () => { + const testCases = [ + { input: '5m', expected: true }, + { input: '30s', expected: true }, + { input: '1h', expected: true }, + { input: '500ms', expected: true }, + { input: '2d', expected: true }, + { input: '1w', expected: true }, + { input: '1y', expected: true }, + { input: '1d2h', expected: true }, + { input: '2h30m', expected: true }, + { input: '1h2m3s', expected: true }, + { input: '250ms2s', expected: true }, + { input: '2h3m4s5ms', expected: true }, + { input: '5', expected: false }, + { input: '5m5', expected: false }, + { input: 'm', expected: false }, + { input: 'd', expected: false }, + { input: '', expected: false }, + { input: '1hms', expected: false }, + { input: '2x', expected: false }, + ]; + testCases.forEach(({ input, expected }) => { + expect(durationWithUnitRegexp.test(input)).toBe(expected); + }); + }); + + it('should not match durations without units or partial units', () => { + const testCases = ['5', '30', '100', '5m5', 'm', 'd']; + testCases.forEach((input) => { + expect(durationWithUnitRegexp.test(input)).toBe(false); }); }); }); @@ -806,7 +861,146 @@ describe('computeStartCompletePosition test', () => { const state = createEditorState(value.expr); const node = syntaxTree(state).resolve(value.pos, -1); const result = computeStartCompletePosition(state, node, value.pos); - expect(value.expectedStart).toEqual(result); + expect(result).toEqual(value.expectedStart); + }); + }); +}); + +describe('computeEndCompletePosition test', () => { + const testCases = [ + { + title: 'cursor at end of metric name', + expr: 'metric_name', + pos: 11, // cursor is at the end + expectedEnd: 11, + }, + { + title: 'cursor in middle of metric name - should extend to end', + expr: 'coredns_cache_hits_total', + pos: 14, // cursor is after 'coredns_cache_' (before 'hits') + expectedEnd: 24, // should extend to end of 'coredns_cache_hits_total' + }, + { + title: 'cursor in middle of metric name inside rate() - should extend to end', + expr: 'rate(coredns_cache_hits_total[2m])', + pos: 19, // cursor is after 'coredns_cache_' (before 'hits') + expectedEnd: 29, // should extend to end of 'coredns_cache_hits_total' + }, + { + title: 'cursor in middle of metric name inside sum(rate()) - should extend to end', + expr: 'sum(rate(coredns_cache_hits_total[2m]))', + pos: 24, // cursor is after 'coredns_cache_' (before 'hits') + expectedEnd: 33, // should extend to end of 'coredns_cache_hits_total' + }, + { + title: 'cursor at beginning of metric name - should extend to end', + expr: 'metric_name', + pos: 1, // cursor after 'm' + expectedEnd: 11, + }, + { + title: 'cursor in middle of incomplete function name - should extend to end', + expr: 'sum_ov', + pos: 4, // cursor after 'sum_' (before 'ov') + expectedEnd: 6, // should extend to end of 'sum_ov' + }, + { + title: 'cursor in middle of incomplete function name within aggregator - should extend to end', + expr: 'sum(sum_ov(foo[5m]))', + pos: 8, // cursor after 'sum_' (before 'ov') + expectedEnd: 10, // should extend to end of 'sum_ov' + }, + { + title: 'empty bracket - ends before the closing bracket', + expr: '{}', + pos: 1, + expectedEnd: 1, + }, + { + title: 'cursor in label matchers - ends before the closing bracket', + expr: 'metric_name{label="value"}', + pos: 12, // cursor after '{' + expectedEnd: 25, + }, + { + title: 'cursor in middle of label name in grouping clause - should extend to end', + expr: 'sum by (instance_name)', + pos: 12, // cursor after 'inst' (before 'ance') + expectedEnd: 21, // should extend to end of 'instance_name' + }, + { + title: 'cursor in middle of label name in label matcher - should extend to end', + expr: 'metric{instance_name="value"}', + pos: 11, // cursor after 'inst' (before 'ance') + expectedEnd: 20, // should extend to end of 'instance_name' + }, + { + title: 'cursor in middle of label name in on() modifier - should extend to end', + expr: 'a / on(instance_name) b', + pos: 11, // cursor after 'inst' (before 'ance') + expectedEnd: 20, // should extend to end of 'instance_name' + }, + { + title: 'cursor in middle of label name in ignoring() modifier - should extend to end', + expr: 'a / ignoring(instance_name) b', + pos: 17, // cursor after 'inst' (before 'ance') + expectedEnd: 26, // should extend to end of 'instance_name' + }, + { + title: 'cursor in middle of function name rate - should extend to end', + expr: 'rate(foo[5m])', + pos: 2, // cursor after 'ra' (before 'te') + expectedEnd: 4, // should extend to end of 'rate' + }, + { + title: 'cursor in middle of function name histogram_quantile - should extend to end', + expr: 'histogram_quantile(0.9, rate(foo[5m]))', + pos: 10, // cursor after 'histogram_' (before 'quantile') + expectedEnd: 18, // should extend to end of 'histogram_quantile' + }, + { + title: 'cursor in middle of aggregator sum - should extend to end', + expr: 'sum(rate(foo[5m]))', + pos: 2, // cursor after 'su' (before 'm') + expectedEnd: 3, // should extend to end of 'sum' + }, + { + title: 'cursor in middle of aggregator count_values - should extend to end', + expr: 'count_values("label", foo)', + pos: 6, // cursor after 'count_' (before 'values') + expectedEnd: 12, // should extend to end of 'count_values' + }, + { + title: 'cursor in middle of nested function - should extend to end', + expr: 'sum(rate(foo[5m]))', + pos: 6, // cursor after 'ra' inside rate (before 'te') + expectedEnd: 8, // should extend to end of 'rate' + }, + { + title: 'cursor at beginning of aggregator - should extend to end', + expr: 'avg by (instance) (rate(foo[5m]))', + pos: 1, // cursor after 'a' (before 'vg') + expectedEnd: 3, // should extend to end of 'avg' + }, + { + title: 'cursor in middle of function name with binary op - should extend to end', + expr: 'rate(foo[5m]) / irate(bar[5m])', + pos: 17, // cursor after 'ir' inside irate (before 'ate') + expectedEnd: 21, // should extend to end of 'irate' + }, + { + title: 'error node - returns pos (cursor position)', + expr: 'metric_name !', + pos: 13, // cursor at '!' (error node) + expectedEnd: 13, // error node returns pos + }, + ]; + testCases.forEach((value) => { + it(value.title, () => { + const state = createEditorState(value.expr); + const node = syntaxTree(state).resolve(value.pos, -1); + const result = computeEndCompletePosition(node, value.pos); + expect(result).toEqual(value.expectedEnd); }); }); }); @@ -860,6 +1054,28 @@ describe('autocomplete promQL test', () => { validFor: /^[a-zA-Z0-9_:]+$/, }, }, + { + title: 'cursor in middle of metric name - to should extend to end (issue #15839)', + expr: 'sum(coredns_cache_hits_total)', + pos: 18, // cursor is after 'coredns_cache_' (before 'hits') + expectedResult: { + options: ([] as Completion[]).concat(functionIdentifierTerms, aggregateOpTerms, snippets), + from: 4, + to: 28, // should extend to end of 'coredns_cache_hits_total' + validFor: /^[a-zA-Z0-9_:]+$/, + }, + }, + { + title: 'cursor in middle of metric name inside rate() - to should extend to end (issue #15839)', + expr: 'rate(coredns_cache_hits_total[2m])', + pos: 19, // cursor is after 'coredns_cache_' (before 'hits') + expectedResult: { + options: ([] as Completion[]).concat(functionIdentifierTerms, aggregateOpTerms, snippets), + from: 5, + to: 29, // should extend to end of 'coredns_cache_hits_total' + validFor: /^[a-zA-Z0-9_:]+$/, + }, + }, { title: 'offline function/aggregation autocompletion in aggregation 3', expr: 'sum(rate())', @@ -1223,6 +1439,28 @@ describe('autocomplete promQL test', () => { validFor: undefined, }, }, + { + title: 'offline do not autocomplete duration when unit already present in matrixSelector', + expr: 'rate(foo[5m])', + pos: 10, + expectedResult: { + options: [], + from: 10, + to: 11, + validFor: /^[a-zA-Z0-9_:]+$/, + }, + }, + { + title: 'offline do not autocomplete duration when multi char unit already present in matrixSelector', + expr: 'rate(foo[5ms])', + pos: 10, + expectedResult: { + options: [], + from: 10, + to: 12, + validFor: /^[a-zA-Z0-9_:]+$/, + }, + }, { title: 'offline autocomplete duration for a subQuery', expr: 'go[5d:5]', @@ -1374,7 +1612,39 @@ describe('autocomplete promQL test', () => { const context = new CompletionContext(state, value.pos, true); const completion = newCompleteStrategy(value.conf); const result = await completion.promQL(context); - expect(value.expectedResult).toEqual(result); + expect(result).toEqual(value.expectedResult); }); }); + + it('online autocomplete of openmetrics counter', async () => { + const metricName = 'direct_notifications_total'; + const baseMetricName = 'direct_notifications'; + nock('http://localhost:8080') + .get('/api/v1/label/__name__/values') + .query(true) + .reply(200, { status: 'success', data: [metricName] }); + nock('http://localhost:8080') + .get('/api/v1/metadata') + .query(true) + .reply(200, { + status: 'success', + data: { + [baseMetricName]: [ + { + type: 'counter', + help: 'Number of direct notifications.', + unit: '', + }, + ], + }, + }); + const state = createEditorState(metricName); + const context = new CompletionContext(state, metricName.length, true); + const completion = newCompleteStrategy({ remote: { url: 'http://localhost:8080' } }); + const result = await completion.promQL(context); + // nock only mocks the HTTP endpoints; this test just ensures remote completion works + // when metadata for an OpenMetrics _total counter is stored under its base metric name. + expect(result).not.toBeNull(); + expect((result as NonNullable).options.length).toBeGreaterThan(0); + }); }); diff --git a/web/ui/module/codemirror-promql/src/complete/hybrid.ts b/web/ui/module/codemirror-promql/src/complete/hybrid.ts index b2d439d2fe..84c101b43c 100644 --- a/web/ui/module/codemirror-promql/src/complete/hybrid.ts +++ b/web/ui/module/codemirror-promql/src/complete/hybrid.ts @@ -166,6 +166,49 @@ function arrayToCompletionResult(data: Completion[], from: number, to: number, i } as CompletionResult; } +// computeEndCompletePosition calculates the end position for autocompletion replacement. +// When the cursor is in the middle of a token, this ensures the entire token is replaced, +// not just the portion before the cursor. This fixes issue #15839. +// Note: this method is exported only for testing purpose. +export function computeEndCompletePosition(node: SyntaxNode, pos: number): number { + // For error nodes, use the cursor position as the end position + if (node.type.id === 0) { + return pos; + } + + if ( + node.type.id === LabelMatchers || + node.type.id === GroupingLabels || + node.type.id === FunctionCallBody || + node.type.id === MatrixSelector || + node.type.id === SubqueryExpr + ) { + // When we're inside empty brackets, we want to replace up to just before the closing bracket. + return node.to - 1; + } + + if (node.type.id === StringLiteral && (node.parent?.type.id === UnquotedLabelMatcher || node.parent?.type.id === QuotedLabelMatcher)) { + // For label values, we want to replace all content inside the quotes. + return node.parent.to - 1; + } + + // For all other nodes, extend the end position to include the entire token. + return node.to; +} + +// Matches complete PromQL durations, including compound units (e.g., 5m, 1d2h, 1h30m, etc.). +// Duration units are a fixed, safe set (no regex metacharacters), so no escaping is needed. +export const durationWithUnitRegexp = new RegExp(`^(\\d+(${durationTerms.map((term) => term.label).join('|')}))+$`); + +// Determines if a duration already has a complete time unit to prevent autocomplete insertion (issue #15452) +function hasCompleteDurationUnit(state: EditorState, node: SyntaxNode): boolean { + if (node.from >= node.to) { + return false; + } + const nodeContent = state.sliceDoc(node.from, node.to); + return durationWithUnitRegexp.test(nodeContent); +} + // computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel calculates the start position only when the node is a LabelMatchers or a GroupingLabels function computeStartCompleteLabelPositionInLabelMatcherOrInGroupingLabel(node: SyntaxNode, pos: number): number { // Here we can have two different situations: @@ -400,12 +443,18 @@ export function analyzeCompletion(state: EditorState, node: SyntaxNode, pos: num // so we have or to autocomplete any kind of labelName or to autocomplete only the labelName associated to the metric result.push({ kind: ContextKind.LabelName, metricName: getMetricNameInGroupBy(node, state) }); break; - case LabelMatchers: + case LabelMatchers: { + if (pos >= node.to) { + // Cursor is outside of the label matcher block (e.g. right after `}`), + // so don't offer label-related completions anymore. + break; + } // In that case we are in the given situation: // metric_name{} or {} // so we have or to autocomplete any kind of labelName or to autocomplete only the labelName associated to the metric result.push({ kind: ContextKind.LabelName, metricName: getMetricNameInVectorSelector(node, state) }); break; + } case LabelName: if (node.parent?.type.id === GroupingLabels) { // In this case we are in the given situation: @@ -471,12 +520,18 @@ export function analyzeCompletion(state: EditorState, node: SyntaxNode, pos: num // Duration, Duration, ⚠(NumberLiteral) // ) // So we should continue to autocomplete a duration - result.push({ kind: ContextKind.Duration }); + if (!hasCompleteDurationUnit(state, node)) { + result.push({ kind: ContextKind.Duration }); + } } else { result.push({ kind: ContextKind.Number }); } break; case NumberDurationLiteralInDurationContext: + if (!hasCompleteDurationUnit(state, node)) { + result.push({ kind: ContextKind.Duration }); + } + break; case OffsetExpr: result.push({ kind: ContextKind.Duration }); break; @@ -550,6 +605,10 @@ export class HybridComplete implements CompleteStrategy { return this.prometheusClient; } + destroy(): void { + this.prometheusClient?.destroy?.(); + } + promQL(context: CompletionContext): Promise | CompletionResult | null { const { state, pos } = context; const tree = syntaxTree(state).resolve(pos, -1); @@ -638,7 +697,13 @@ export class HybridComplete implements CompleteStrategy { } } return asyncResult.then((result) => { - return arrayToCompletionResult(result, computeStartCompletePosition(state, tree, pos), pos, completeSnippet, span); + return arrayToCompletionResult( + result, + computeStartCompletePosition(state, tree, pos), + computeEndCompletePosition(tree, pos), + completeSnippet, + span + ); }); } @@ -664,11 +729,10 @@ export class HybridComplete implements CompleteStrategy { .then((metricMetadata) => { if (metricMetadata) { for (const [metricName, node] of metricCompletion) { - // First check if the full metric name has metadata (even if it has one of the - // histogram/summary suffixes, it may be a metric that is not following naming - // conventions, see https://github.com/prometheus/prometheus/issues/16907). - // Then fall back to the base metric name if full metadata doesn't exist. - const metadata = metricMetadata[metricName] ?? metricMetadata[metricName.replace(/(_count|_sum|_bucket)$/, '')]; + // First check if the full metric name has metadata (even if it has one of the histogram/summary/openmetrics suffixes + // it may be a metric that is not following naming conventions) + // Then fall back to the base metric name if full metadata doesn't exist + const metadata = metricMetadata[metricName] ?? metricMetadata[metricName.replace(/(_count|_sum|_bucket|_total)$/, '')]; if (metadata) { if (metadata.length > 1) { // it means the metricName has different possible helper and type diff --git a/web/ui/module/codemirror-promql/src/complete/index.ts b/web/ui/module/codemirror-promql/src/complete/index.ts index b3902c3b6b..dd73857639 100644 --- a/web/ui/module/codemirror-promql/src/complete/index.ts +++ b/web/ui/module/codemirror-promql/src/complete/index.ts @@ -19,6 +19,7 @@ import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; // Every different completion mode must implement this interface. export interface CompleteStrategy { promQL(context: CompletionContext): Promise | CompletionResult | null; + destroy?(): void; } // CompleteConfiguration should be used to customize the autocompletion. diff --git a/web/ui/module/codemirror-promql/src/complete/promql.terms.ts b/web/ui/module/codemirror-promql/src/complete/promql.terms.ts index d356268d74..68d7b06553 100644 --- a/web/ui/module/codemirror-promql/src/complete/promql.terms.ts +++ b/web/ui/module/codemirror-promql/src/complete/promql.terms.ts @@ -39,6 +39,10 @@ export const binOpModifierTerms = [ { label: 'ignoring', info: 'Ignore specified labels for matching', type: 'keyword' }, { label: 'group_left', info: 'Allow many-to-one matching', type: 'keyword' }, { label: 'group_right', info: 'Allow one-to-many matching', type: 'keyword' }, + { label: 'bool', info: 'Return boolean result (0 or 1) instead of filtering', type: 'keyword' }, + { label: 'fill', info: 'Fill in missing series on both sides', type: 'keyword' }, + { label: 'fill_left', info: 'Fill in missing series on the left side', type: 'keyword' }, + { label: 'fill_right', info: 'Fill in missing series on the right side', type: 'keyword' }, ]; export const atModifierTerms = [ @@ -239,6 +243,12 @@ export const functionIdentifierTerms = [ info: 'Calculate quantiles from native histograms and from conventional histogram buckets', type: 'function', }, + { + label: 'histogram_quantiles', + detail: 'function', + info: 'Calculate multiple quantiles from native histograms and from conventional histogram buckets', + type: 'function', + }, { label: 'histogram_sum', detail: 'function', diff --git a/web/ui/module/codemirror-promql/src/parser/vector.test.ts b/web/ui/module/codemirror-promql/src/parser/vector.test.ts index f628206538..c6eeb930ab 100644 --- a/web/ui/module/codemirror-promql/src/parser/vector.test.ts +++ b/web/ui/module/codemirror-promql/src/parser/vector.test.ts @@ -15,29 +15,31 @@ import { buildVectorMatching } from './vector'; import { createEditorState } from '../test/utils-test'; import { BinaryExpr } from '@prometheus-io/lezer-promql'; import { syntaxTree } from '@codemirror/language'; -import { VectorMatchCardinality } from '../types'; +import { VectorMatchCardinality, VectorMatching } from '../types'; + +const noFill = { fill: { lhs: null, rhs: null } }; describe('buildVectorMatching test', () => { - const testCases = [ + const testCases: { binaryExpr: string; expectedVectorMatching: VectorMatching }[] = [ { binaryExpr: 'foo * bar', - expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] }, + expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill }, }, { binaryExpr: 'foo * sum', - expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] }, + expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill }, }, { binaryExpr: 'foo == 1', - expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] }, + expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill }, }, { binaryExpr: 'foo == bool 1', - expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] }, + expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill }, }, { binaryExpr: '2.5 / bar', - expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [] }, + expectedVectorMatching: { card: VectorMatchCardinality.CardOneToOne, matchingLabels: [], on: false, include: [], ...noFill }, }, { binaryExpr: 'foo and bar', @@ -46,6 +48,7 @@ describe('buildVectorMatching test', () => { matchingLabels: [], on: false, include: [], + ...noFill, }, }, { @@ -55,6 +58,7 @@ describe('buildVectorMatching test', () => { matchingLabels: [], on: false, include: [], + ...noFill, }, }, { @@ -64,6 +68,7 @@ describe('buildVectorMatching test', () => { matchingLabels: [], on: false, include: [], + ...noFill, }, }, { @@ -75,6 +80,7 @@ describe('buildVectorMatching test', () => { matchingLabels: [], on: false, include: [], + ...noFill, }, }, { @@ -86,6 +92,7 @@ describe('buildVectorMatching test', () => { matchingLabels: [], on: false, include: [], + ...noFill, }, }, { @@ -95,6 +102,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: true, include: [], + ...noFill, }, }, { @@ -104,6 +112,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: true, include: [], + ...noFill, }, }, { @@ -113,6 +122,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: true, include: [], + ...noFill, }, }, { @@ -122,6 +132,7 @@ describe('buildVectorMatching test', () => { matchingLabels: [], on: true, include: [], + ...noFill, }, }, { @@ -131,6 +142,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: false, include: [], + ...noFill, }, }, { @@ -140,6 +152,7 @@ describe('buildVectorMatching test', () => { matchingLabels: [], on: false, include: [], + ...noFill, }, }, { @@ -149,6 +162,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['bar'], on: true, include: [], + ...noFill, }, }, { @@ -158,6 +172,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: true, include: ['bar'], + ...noFill, }, }, { @@ -167,6 +182,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: false, include: ['blub'], + ...noFill, }, }, { @@ -176,6 +192,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: false, include: ['bar'], + ...noFill, }, }, { @@ -185,6 +202,7 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: true, include: ['bar', 'foo'], + ...noFill, }, }, { @@ -194,6 +212,57 @@ describe('buildVectorMatching test', () => { matchingLabels: ['test', 'blub'], on: false, include: ['bar', 'foo'], + ...noFill, + }, + }, + { + binaryExpr: 'foo + fill(23) bar', + expectedVectorMatching: { + card: VectorMatchCardinality.CardOneToOne, + matchingLabels: [], + on: false, + include: [], + fill: { lhs: 23, rhs: 23 }, + }, + }, + { + binaryExpr: 'foo + fill_left(23) bar', + expectedVectorMatching: { + card: VectorMatchCardinality.CardOneToOne, + matchingLabels: [], + on: false, + include: [], + fill: { lhs: 23, rhs: null }, + }, + }, + { + binaryExpr: 'foo + fill_right(23) bar', + expectedVectorMatching: { + card: VectorMatchCardinality.CardOneToOne, + matchingLabels: [], + on: false, + include: [], + fill: { lhs: null, rhs: 23 }, + }, + }, + { + binaryExpr: 'foo + fill_left(23) fill_right(42) bar', + expectedVectorMatching: { + card: VectorMatchCardinality.CardOneToOne, + matchingLabels: [], + on: false, + include: [], + fill: { lhs: 23, rhs: 42 }, + }, + }, + { + binaryExpr: 'foo + fill_right(23) fill_left(42) bar', + expectedVectorMatching: { + card: VectorMatchCardinality.CardOneToOne, + matchingLabels: [], + on: false, + include: [], + fill: { lhs: 42, rhs: 23 }, }, }, ]; @@ -203,7 +272,7 @@ describe('buildVectorMatching test', () => { const node = syntaxTree(state).topNode.getChild(BinaryExpr); expect(node).toBeTruthy(); if (node) { - expect(value.expectedVectorMatching).toEqual(buildVectorMatching(state, node)); + expect(buildVectorMatching(state, node)).toEqual(value.expectedVectorMatching); } }); }); diff --git a/web/ui/module/codemirror-promql/src/parser/vector.ts b/web/ui/module/codemirror-promql/src/parser/vector.ts index c47ca1fb76..9fc31bf5c6 100644 --- a/web/ui/module/codemirror-promql/src/parser/vector.ts +++ b/web/ui/module/codemirror-promql/src/parser/vector.ts @@ -24,6 +24,11 @@ import { On, Or, Unless, + NumberDurationLiteral, + FillModifier, + FillClause, + FillLeftClause, + FillRightClause, } from '@prometheus-io/lezer-promql'; import { VectorMatchCardinality, VectorMatching } from '../types'; import { containsAtLeastOneChild } from './path-finder'; @@ -37,6 +42,10 @@ export function buildVectorMatching(state: EditorState, binaryNode: SyntaxNode): matchingLabels: [], on: false, include: [], + fill: { + lhs: null, + rhs: null, + }, }; const modifierClause = binaryNode.getChild(MatchingModifierClause); if (modifierClause) { @@ -60,6 +69,32 @@ export function buildVectorMatching(state: EditorState, binaryNode: SyntaxNode): } } + const fillModifier = binaryNode.getChild(FillModifier); + if (fillModifier) { + const fill = fillModifier.getChild(FillClause); + const fillLeft = fillModifier.getChild(FillLeftClause); + const fillRight = fillModifier.getChild(FillRightClause); + + const getFillValue = (node: SyntaxNode) => { + const valueNode = node.getChild(NumberDurationLiteral); + return valueNode ? parseFloat(state.sliceDoc(valueNode.from, valueNode.to)) : null; + }; + + if (fill) { + const value = getFillValue(fill); + result.fill.lhs = value; + result.fill.rhs = value; + } + + if (fillLeft) { + result.fill.lhs = getFillValue(fillLeft); + } + + if (fillRight) { + result.fill.rhs = getFillValue(fillRight); + } + } + const isSetOperator = containsAtLeastOneChild(binaryNode, And, Or, Unless); if (isSetOperator && result.card === VectorMatchCardinality.CardOneToOne) { result.card = VectorMatchCardinality.CardManyToMany; diff --git a/web/ui/module/codemirror-promql/src/promql.test.ts b/web/ui/module/codemirror-promql/src/promql.test.ts new file mode 100644 index 0000000000..787747cc5e --- /dev/null +++ b/web/ui/module/codemirror-promql/src/promql.test.ts @@ -0,0 +1,58 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { PromQLExtension } from './promql'; +import { CompleteStrategy } from './complete'; +import { CompletionResult } from '@codemirror/autocomplete'; + +describe('PromQLExtension destroy', () => { + it('should be safe to call destroy multiple times', () => { + const extension = new PromQLExtension(); + // First call + extension.destroy(); + // Second call should not throw + expect(() => extension.destroy()).not.toThrow(); + }); + + it('should call destroy on the complete strategy if available', () => { + const extension = new PromQLExtension(); + + // Set up a mock complete strategy with destroy + let destroyCalled = false; + const mockCompleteStrategy: CompleteStrategy = { + promQL: (): CompletionResult | null => null, + destroy: () => { + destroyCalled = true; + }, + }; + + extension.setComplete({ completeStrategy: mockCompleteStrategy }); + extension.destroy(); + + expect(destroyCalled).toBe(true); + }); + + it('should handle complete strategies without destroy method', () => { + const extension = new PromQLExtension(); + + // Set up a mock complete strategy without destroy + const mockCompleteStrategy: CompleteStrategy = { + promQL: (): CompletionResult | null => null, + }; + + extension.setComplete({ completeStrategy: mockCompleteStrategy }); + + // Should not throw even though complete strategy has no destroy + expect(() => extension.destroy()).not.toThrow(); + }); +}); diff --git a/web/ui/module/codemirror-promql/src/promql.ts b/web/ui/module/codemirror-promql/src/promql.ts index 506cd1348b..859442559f 100644 --- a/web/ui/module/codemirror-promql/src/promql.ts +++ b/web/ui/module/codemirror-promql/src/promql.ts @@ -79,6 +79,10 @@ export class PromQLExtension { return this; } + destroy(): void { + this.complete.destroy?.(); + } + asExtension(languageType = LanguageType.PromQL): Extension { const language = promQLLanguage(languageType); let extension: Extension = [language]; diff --git a/web/ui/module/codemirror-promql/src/types/function.ts b/web/ui/module/codemirror-promql/src/types/function.ts index cfbf3524b5..cc1c0524fb 100644 --- a/web/ui/module/codemirror-promql/src/types/function.ts +++ b/web/ui/module/codemirror-promql/src/types/function.ts @@ -44,6 +44,7 @@ import { HistogramCount, HistogramFraction, HistogramQuantile, + HistogramQuantiles, HistogramStdDev, HistogramStdVar, HistogramSum, @@ -306,6 +307,12 @@ const promqlFunctions: { [key: number]: PromQLFunction } = { variadic: 0, returnType: ValueType.vector, }, + [HistogramQuantiles]: { + name: 'histogram_quantiles', + argTypes: [ValueType.vector, ValueType.string, ValueType.scalar, ValueType.scalar], + variadic: 10, + returnType: ValueType.vector, + }, [HistogramStdDev]: { name: 'histogram_stddev', argTypes: [ValueType.vector], diff --git a/web/ui/module/codemirror-promql/src/types/vector.ts b/web/ui/module/codemirror-promql/src/types/vector.ts index 4e7a4f4c45..709b0b76d6 100644 --- a/web/ui/module/codemirror-promql/src/types/vector.ts +++ b/web/ui/module/codemirror-promql/src/types/vector.ts @@ -18,6 +18,11 @@ export enum VectorMatchCardinality { CardManyToMany = 'many-to-many', } +export interface FillValues { + lhs: number | null; + rhs: number | null; +} + export interface VectorMatching { // The cardinality of the two Vectors. card: VectorMatchCardinality; @@ -30,4 +35,6 @@ export interface VectorMatching { // Include contains additional labels that should be included in // the result from the side with the lower cardinality. include: string[]; + // Fill contains optional fill values for missing elements. + fill: FillValues; } diff --git a/web/ui/module/lezer-promql/package.json b/web/ui/module/lezer-promql/package.json index 5e1ab771f1..7a969b57e4 100644 --- a/web/ui/module/lezer-promql/package.json +++ b/web/ui/module/lezer-promql/package.json @@ -1,6 +1,6 @@ { "name": "@prometheus-io/lezer-promql", - "version": "0.307.3", + "version": "0.309.1", "description": "lezer-based PromQL grammar", "main": "dist/index.cjs", "type": "module", @@ -32,9 +32,9 @@ }, "devDependencies": { "@lezer/generator": "^1.8.0", - "@lezer/highlight": "^1.2.1", - "@lezer/lr": "^1.4.2", - "@rollup/plugin-node-resolve": "^16.0.1" + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.8", + "@rollup/plugin-node-resolve": "^16.0.3" }, "peerDependencies": { "@lezer/highlight": "^1.1.2", diff --git a/web/ui/module/lezer-promql/src/highlight.js b/web/ui/module/lezer-promql/src/highlight.js index 9c1b5601a3..b452373345 100644 --- a/web/ui/module/lezer-promql/src/highlight.js +++ b/web/ui/module/lezer-promql/src/highlight.js @@ -1,4 +1,4 @@ -// Copyright 2022 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/web/ui/module/lezer-promql/src/promql.grammar b/web/ui/module/lezer-promql/src/promql.grammar index 5fe8d4d025..e4308186bb 100644 --- a/web/ui/module/lezer-promql/src/promql.grammar +++ b/web/ui/module/lezer-promql/src/promql.grammar @@ -101,11 +101,30 @@ MatchingModifierClause { ((GroupLeft | GroupRight) (!group GroupingLabels)?)? } +FillClause { + Fill "(" NumberDurationLiteral ")" +} + +FillLeftClause { + FillLeft "(" NumberDurationLiteral ")" +} + +FillRightClause { + FillRight "(" NumberDurationLiteral ")" +} + +FillModifier { + (FillClause | FillLeftClause | FillRightClause) | + (FillLeftClause FillRightClause) | + (FillRightClause FillLeftClause) +} + BoolModifier { Bool } binModifiers { BoolModifier? MatchingModifierClause? + FillModifier? } GroupingLabels { @@ -148,6 +167,7 @@ FunctionIdentifier { HistogramCount | HistogramFraction | HistogramQuantile | + HistogramQuantiles | HistogramStdDev | HistogramStdVar | HistogramSum | @@ -366,7 +386,10 @@ NumberDurationLiteralInDurationContext { Start, End, Smoothed, - Anchored + Anchored, + Fill, + FillLeft, + FillRight } @external propSource promQLHighLight from "./highlight" @@ -404,6 +427,7 @@ NumberDurationLiteralInDurationContext { HistogramCount { condFn<"histogram_count"> } HistogramFraction { condFn<"histogram_fraction"> } HistogramQuantile { condFn<"histogram_quantile"> } + HistogramQuantiles { condFn<"histogram_quantiles"> } HistogramStdDev { condFn<"histogram_stddev"> } HistogramStdVar { condFn<"histogram_stdvar"> } HistogramSum { condFn<"histogram_sum"> } diff --git a/web/ui/module/lezer-promql/src/tokens.js b/web/ui/module/lezer-promql/src/tokens.js index 1695ae1d87..6fd681f1f8 100644 --- a/web/ui/module/lezer-promql/src/tokens.js +++ b/web/ui/module/lezer-promql/src/tokens.js @@ -1,4 +1,4 @@ -// Copyright 2021 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -12,82 +12,88 @@ // limitations under the License. import { - And, - Avg, - Atan2, - Bool, - Bottomk, - By, - Count, - CountValues, - End, - Group, - GroupLeft, - GroupRight, - Ignoring, - inf, - Max, - Min, - nan, - Offset, - On, - Or, - Quantile, - LimitK, - LimitRatio, - Start, - Stddev, - Stdvar, - Sum, - Topk, - Unless, - Without, - Smoothed, - Anchored, -} from './parser.terms.js'; + And, + Avg, + Atan2, + Bool, + Bottomk, + By, + Count, + CountValues, + End, + Group, + GroupLeft, + GroupRight, + Ignoring, + inf, + Max, + Min, + nan, + Offset, + On, + Or, + Quantile, + LimitK, + LimitRatio, + Start, + Stddev, + Stdvar, + Sum, + Topk, + Unless, + Without, + Smoothed, + Anchored, + Fill, + FillLeft, + FillRight, +} from "./parser.terms.js"; const keywordTokens = { - inf: inf, - nan: nan, - bool: Bool, - ignoring: Ignoring, - on: On, - group_left: GroupLeft, - group_right: GroupRight, - offset: Offset, + inf: inf, + nan: nan, + bool: Bool, + ignoring: Ignoring, + on: On, + group_left: GroupLeft, + group_right: GroupRight, + offset: Offset, }; export const specializeIdentifier = (value, stack) => { - return keywordTokens[value.toLowerCase()] || -1; + return keywordTokens[value.toLowerCase()] || -1; }; const contextualKeywordTokens = { - avg: Avg, - atan2: Atan2, - bottomk: Bottomk, - count: Count, - count_values: CountValues, - group: Group, - max: Max, - min: Min, - quantile: Quantile, - limitk: LimitK, - limit_ratio: LimitRatio, - stddev: Stddev, - stdvar: Stdvar, - sum: Sum, - topk: Topk, - by: By, - without: Without, - and: And, - or: Or, - unless: Unless, - start: Start, - end: End, - smoothed: Smoothed, - anchored: Anchored, + avg: Avg, + atan2: Atan2, + bottomk: Bottomk, + count: Count, + count_values: CountValues, + group: Group, + max: Max, + min: Min, + quantile: Quantile, + limitk: LimitK, + limit_ratio: LimitRatio, + stddev: Stddev, + stdvar: Stdvar, + sum: Sum, + topk: Topk, + by: By, + without: Without, + and: And, + or: Or, + unless: Unless, + start: Start, + end: End, + smoothed: Smoothed, + anchored: Anchored, + fill: Fill, + fill_left: FillLeft, + fill_right: FillRight, }; export const extendIdentifier = (value, stack) => { - return contextualKeywordTokens[value.toLowerCase()] || -1; + return contextualKeywordTokens[value.toLowerCase()] || -1; }; diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json index 144a5c76d5..7669399b66 100644 --- a/web/ui/package-lock.json +++ b/web/ui/package-lock.json @@ -1,125 +1,110 @@ { "name": "prometheus-io", - "version": "0.307.3", + "version": "0.309.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prometheus-io", - "version": "0.307.3", + "version": "0.309.1", "workspaces": [ "mantine-ui", "module/*" ], "devDependencies": { "@types/jest": "^29.5.14", - "@typescript-eslint/eslint-plugin": "^8.45.0", - "@typescript-eslint/parser": "^8.45.0", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", "eslint-config-prettier": "^10.1.8", - "prettier": "^3.6.2", - "ts-jest": "^29.4.4", + "prettier": "^3.8.1", + "ts-jest": "^29.4.6", "typescript": "^5.9.3", - "vite": "^6.3.6" + "vite": "^6.4.1" } }, "mantine-ui": { "name": "@prometheus-io/mantine-ui", - "version": "0.307.3", + "version": "0.309.1", "dependencies": { - "@codemirror/autocomplete": "^6.19.0", - "@codemirror/language": "^6.11.3", - "@codemirror/lint": "^6.8.5", - "@codemirror/state": "^6.5.2", - "@codemirror/view": "^6.38.4", - "@floating-ui/dom": "^1.7.4", - "@lezer/common": "^1.2.3", - "@lezer/highlight": "^1.2.1", - "@mantine/code-highlight": "^8.3.5", - "@mantine/core": "^8.3.5", - "@mantine/dates": "^8.3.5", - "@mantine/hooks": "^8.3.5", - "@mantine/notifications": "^8.3.5", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/language": "^6.12.1", + "@codemirror/lint": "^6.9.3", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.12", + "@floating-ui/dom": "^1.7.5", + "@lezer/common": "^1.5.1", + "@lezer/highlight": "^1.2.3", + "@mantine/code-highlight": "^8.3.14", + "@mantine/core": "^8.3.14", + "@mantine/dates": "^8.3.14", + "@mantine/hooks": "^8.3.14", + "@mantine/notifications": "^8.3.14", "@microsoft/fetch-event-source": "^2.0.1", "@nexucis/fuzzy": "^0.5.1", "@nexucis/kvsearch": "^0.9.1", - "@prometheus-io/codemirror-promql": "0.307.3", - "@reduxjs/toolkit": "^2.9.0", - "@tabler/icons-react": "^3.35.0", - "@tanstack/react-query": "^5.90.2", - "@testing-library/jest-dom": "^6.9.0", - "@testing-library/react": "^16.3.0", - "@types/lodash": "^4.17.20", + "@prometheus-io/codemirror-promql": "0.309.1", + "@reduxjs/toolkit": "^2.11.2", + "@tabler/icons-react": "^3.36.1", + "@tanstack/react-query": "^5.90.20", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/lodash": "^4.17.23", "@types/sanitize-html": "^2.16.0", - "@uiw/react-codemirror": "^4.25.2", + "@uiw/react-codemirror": "^4.25.4", "clsx": "^2.1.1", - "dayjs": "^1.11.18", + "dayjs": "^1.11.19", "highlight.js": "^11.11.1", - "lodash": "^4.17.21", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "react-infinite-scroll-component": "^6.1.0", + "lodash": "^4.17.23", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-infinite-scroll-component": "^6.1.1", "react-redux": "^9.2.0", - "react-router-dom": "^7.9.3", + "react-router-dom": "^7.13.0", "sanitize-html": "^2.17.0", "uplot": "^1.6.32", "uplot-react": "^1.2.4", - "use-query-params": "^2.2.1" + "use-query-params": "^2.2.2" }, "devDependencies": { - "@eslint/compat": "^1.4.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.36.0", - "@types/react": "^19.1.16", - "@types/react-dom": "^19.1.9", - "@typescript-eslint/eslint-plugin": "^8.45.0", - "@typescript-eslint/parser": "^8.45.0", + "@eslint/compat": "^1.4.1", + "@eslint/eslintrc": "^3.3.3", + "@eslint/js": "^9.39.2", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-react": "^4.7.0", - "eslint": "^9.36.0", + "eslint": "^9.39.2", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.22", - "globals": "^16.4.0", + "eslint-plugin-react-refresh": "^0.5.0", + "globals": "^16.5.0", "jsdom": "^25.0.1", "postcss": "^8.5.6", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", - "vite": "^6.3.6", + "vite": "^6.4.1", "vitest": "^3.2.4" } }, - "mantine-ui/node_modules/@floating-ui/react": { - "version": "0.27.16", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", - "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.1.6", - "@floating-ui/utils": "^0.2.10", - "tabbable": "^6.0.0" - }, - "peerDependencies": { - "react": ">=17.0.0", - "react-dom": ">=17.0.0" - } - }, "mantine-ui/node_modules/@mantine/code-highlight": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-8.3.5.tgz", - "integrity": "sha512-KZhYPilo6hUbJf/ls0e/lCjXKWI6/cnzkMkSRLAEVLD9HrhJKIBonwQtLRFgJbLmxKu9IE9KuJjq9lA5kUJCFQ==", + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/code-highlight/-/code-highlight-8.3.14.tgz", + "integrity": "sha512-7ywMnadaw4O/QG9sQOCIWPZKh6Q97ibyZgkH2cjVNvVbChmZKXIlcHW/QbQJUS84Bs/eGDhnkxwnq78v9w16gQ==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { - "@mantine/core": "8.3.5", - "@mantine/hooks": "8.3.5", + "@mantine/core": "8.3.14", + "@mantine/hooks": "8.3.14", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "mantine-ui/node_modules/@mantine/core": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz", - "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==", + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.14.tgz", + "integrity": "sha512-ZOxggx65Av1Ii1NrckCuqzluRpmmG+8DyEw24wDom3rmwsPg9UV+0le2QTyI5Eo60LzPfUju1KuEPiUzNABIPg==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.16", @@ -130,56 +115,56 @@ "type-fest": "^4.41.0" }, "peerDependencies": { - "@mantine/hooks": "8.3.5", + "@mantine/hooks": "8.3.14", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "mantine-ui/node_modules/@mantine/dates": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.5.tgz", - "integrity": "sha512-LkIdC4eWPNQFv1BU1c52U3Z3RuA3yU1asvTgMEIQ/MdJsGK8GePwpgMH/jKQ8ba/AW9NfksdvtOJ6uIqPwjCkg==", + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.3.14.tgz", + "integrity": "sha512-NdStRo2ZQ55MoMF5B9vjhpBpHRDHF1XA9Dkb1kKSdNuLlaFXKlvoaZxj/3LfNPpn7Nqlns78nWt4X8/cgC2YIg==", "license": "MIT", "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { - "@mantine/core": "8.3.5", - "@mantine/hooks": "8.3.5", + "@mantine/core": "8.3.14", + "@mantine/hooks": "8.3.14", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "mantine-ui/node_modules/@mantine/hooks": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz", - "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==", + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.14.tgz", + "integrity": "sha512-0SbHnGEuHcF2QyjzBBcqidpjNmIb6n7TC3obnhkBToYhUTbMcJSK/8ei/yHtAelridJH4CPeohRlQdc0HajHyQ==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" } }, "mantine-ui/node_modules/@mantine/notifications": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.5.tgz", - "integrity": "sha512-8TvzrPxfdtOLGTalv7Ei1hy2F6KbR3P7/V73yw3AOKhrf1ydS89sqV2ShbsucHGJk9Pto0wjdTPd8Q7pm5MAYw==", + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.14.tgz", + "integrity": "sha512-+ia97wrcU9Zfv+jXYvgr2GdISqKTHbQE9nnEIZvGUBPAqKr9b2JAsaXQS/RsAdoXUI+kKDEtH2fyVYS7zrSi/Q==", "license": "MIT", "dependencies": { - "@mantine/store": "8.3.5", + "@mantine/store": "8.3.14", "react-transition-group": "4.4.5" }, "peerDependencies": { - "@mantine/core": "8.3.5", - "@mantine/hooks": "8.3.5", + "@mantine/core": "8.3.14", + "@mantine/hooks": "8.3.14", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "mantine-ui/node_modules/@mantine/store": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.5.tgz", - "integrity": "sha512-qN4fFsDMy86IV9oh1gZlDTv41RAsO0grjx90FGyT5QCv7NTgcavwxB74GBkhp45W8xn+Ms/awKy+6NxnmLmW1w==", + "version": "8.3.14", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.14.tgz", + "integrity": "sha512-bgW+fYHDOp7Pk4+lcEm3ZF7dD/sIMKHyR985cOqSHAYJPRcVFb+zcEK/SWoFZqlyA4qh08CNrASOaod8N0XKfA==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" @@ -187,22 +172,22 @@ }, "module/codemirror-promql": { "name": "@prometheus-io/codemirror-promql", - "version": "0.307.3", + "version": "0.309.1", "license": "Apache-2.0", "dependencies": { - "@prometheus-io/lezer-promql": "0.307.3", - "lru-cache": "^11.2.2" + "@prometheus-io/lezer-promql": "0.309.1", + "lru-cache": "^11.2.5" }, "devDependencies": { - "@codemirror/autocomplete": "^6.19.0", - "@codemirror/language": "^6.11.3", - "@codemirror/lint": "^6.8.5", - "@codemirror/state": "^6.5.2", - "@codemirror/view": "^6.38.4", - "@lezer/common": "^1.2.3", - "@lezer/highlight": "^1.2.1", - "@lezer/lr": "^1.4.2", - "eslint-plugin-prettier": "^5.5.4", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/language": "^6.12.1", + "@codemirror/lint": "^6.9.3", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.12", + "@lezer/common": "^1.5.1", + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.8", + "eslint-plugin-prettier": "^5.5.5", "isomorphic-fetch": "^3.0.0", "nock": "^14.0.10" }, @@ -220,13 +205,13 @@ }, "module/lezer-promql": { "name": "@prometheus-io/lezer-promql", - "version": "0.307.3", + "version": "0.309.1", "license": "Apache-2.0", "devDependencies": { "@lezer/generator": "^1.8.0", - "@lezer/highlight": "^1.2.1", - "@lezer/lr": "^1.4.2", - "@rollup/plugin-node-resolve": "^16.0.1" + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.8", + "@rollup/plugin-node-resolve": "^16.0.3" }, "peerDependencies": { "@lezer/highlight": "^1.1.2", @@ -826,9 +811,9 @@ "peer": true }, "node_modules/@codemirror/autocomplete": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.0.tgz", - "integrity": "sha512-61Hfv3cF07XvUxNeC3E7jhG8XNi1Yom1G0lRC936oLnlF+jrbrv8rc/J98XlYzcsAoTVupfsf5fLej1aI8kyIg==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -850,23 +835,23 @@ } }, "node_modules/@codemirror/language": { - "version": "6.11.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", - "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "node_modules/@codemirror/lint": { - "version": "6.8.5", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", - "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.3.tgz", + "integrity": "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -886,9 +871,9 @@ } }, "node_modules/@codemirror/state": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz", + "integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" @@ -907,9 +892,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.38.4", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.4.tgz", - "integrity": "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==", + "version": "6.39.12", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.12.tgz", + "integrity": "sha512-f+/VsHVn/kOA9lltk/GFzuYwVVAKmOnNjxbrhkk3tPHntFqjWeI2TbIXx006YkBkqC10wZ4NsnWXCQiFPeAISQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -1344,9 +1329,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1376,9 +1361,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -1386,13 +1371,12 @@ } }, "node_modules/@eslint/compat": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz", - "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", + "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1407,13 +1391,12 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -1422,21 +1405,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -1445,9 +1429,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1457,7 +1441,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -1481,9 +1465,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", - "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -1494,66 +1478,64 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@floating-ui/react-dom": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", - "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.7.4" }, @@ -1676,9 +1658,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "peer": true, @@ -2149,9 +2131,10 @@ } }, "node_modules/@lezer/common": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", - "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==" + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" }, "node_modules/@lezer/generator": { "version": "1.8.0", @@ -2168,18 +2151,17 @@ } }, "node_modules/@lezer/highlight": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", - "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", - "license": "MIT", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "dependencies": { - "@lezer/common": "^1.0.0" + "@lezer/common": "^1.3.0" } }, "node_modules/@lezer/lr": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", - "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -2229,44 +2211,6 @@ "@nexucis/fuzzy": "^0.5.1" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", @@ -2318,14 +2262,14 @@ "link": true }, "node_modules/@reduxjs/toolkit": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", - "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", - "immer": "^10.0.3", + "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" @@ -2351,11 +2295,10 @@ "license": "MIT" }, "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", - "integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, - "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", @@ -2723,12 +2666,12 @@ } }, "node_modules/@tabler/icons-react": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.35.0.tgz", - "integrity": "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==", + "version": "3.36.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.36.1.tgz", + "integrity": "sha512-/8nOXeNeMoze9xY/QyEKG65wuvRhkT3q9aytaur6Gj8bYU2A98YVJyLc9MRmc5nVvpy+bRlrrwK/Ykr8WGyUWg==", "license": "MIT", "dependencies": { - "@tabler/icons": "3.35.0" + "@tabler/icons": "" }, "funding": { "type": "github", @@ -2739,9 +2682,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", - "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -2749,12 +2692,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", - "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.2" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -2785,10 +2728,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.0.tgz", - "integrity": "sha512-QHdxYMJ0YPGKYofMc6zYvo7LOViVhdc6nPg/OtM2cf9MQrwEcTxFCs7d/GJ5eSyPkHzOiBkc/KfLdFJBHzldtQ==", - "license": "MIT", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", @@ -2810,9 +2752,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" @@ -2998,13 +2940,12 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", "license": "MIT" }, "node_modules/@types/node": { @@ -3017,23 +2958,23 @@ } }, "node_modules/@types/react": { - "version": "19.1.16", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.16.tgz", - "integrity": "sha512-WBM/nDbEZmDUORKnh5i1bTnAz6vTohUf9b8esSMu+b24+srbaxa04UbJgWx78CVfNXA20sNu0odEIluZDFdCog==", + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "devOptional": true, "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", - "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/resolve": { @@ -3083,21 +3024,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", - "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/type-utils": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3107,7 +3047,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.45.0", + "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -3123,17 +3063,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", - "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3148,15 +3088,15 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", - "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.45.0", - "@typescript-eslint/types": "^8.45.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3170,14 +3110,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", - "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3188,9 +3128,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", - "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", "dev": true, "license": "MIT", "engines": { @@ -3205,17 +3145,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", - "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0", - "@typescript-eslint/utils": "8.45.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3230,9 +3170,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", - "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", "dev": true, "license": "MIT", "engines": { @@ -3244,22 +3184,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", - "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.45.0", - "@typescript-eslint/tsconfig-utils": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/visitor-keys": "8.45.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3299,16 +3238,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", - "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.45.0", - "@typescript-eslint/types": "8.45.0", - "@typescript-eslint/typescript-estree": "8.45.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3323,13 +3262,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.45.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", - "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3341,9 +3280,9 @@ } }, "node_modules/@uiw/codemirror-extensions-basic-setup": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.2.tgz", - "integrity": "sha512-s2fbpdXrSMWEc86moll/d007ZFhu6jzwNu5cWv/2o7egymvLeZO52LWkewgbr+BUCGWGPsoJVWeaejbsb/hLcw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz", + "integrity": "sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", @@ -3368,16 +3307,16 @@ } }, "node_modules/@uiw/react-codemirror": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.2.tgz", - "integrity": "sha512-XP3R1xyE0CP6Q0iR0xf3ed+cJzJnfmbLelgJR6osVVtMStGGZP3pGQjjwDRYptmjGHfEELUyyBLdY25h0BQg7w==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.4.tgz", + "integrity": "sha512-ipO067oyfUw+DVaXhQCxkB0ZD9b7RnY+ByrprSYSKCHaULvJ3sqWYC/Zen6zVQ8/XC4o5EPBfatGiX20kC7XGA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", - "@uiw/codemirror-extensions-basic-setup": "4.25.2", + "@uiw/codemirror-extensions-basic-setup": "4.25.4", "codemirror": "^6.0.0" }, "funding": { @@ -3837,9 +3776,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -3936,6 +3875,20 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4172,12 +4125,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/create-jest": { @@ -4256,9 +4213,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/data-urls": { @@ -4276,10 +4233,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.18", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", - "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", - "license": "MIT" + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" }, "node_modules/debug": { "version": "4.4.3", @@ -4464,6 +4420,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.228", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz", @@ -4516,6 +4487,26 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -4523,6 +4514,35 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", @@ -4587,25 +4607,24 @@ } }, "node_modules/eslint": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", - "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.36.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -4664,14 +4683,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", - "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -4708,13 +4727,13 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.22", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.22.tgz", - "integrity": "sha512-atkAG6QaJMGoTLc4MDAP+rqZcfwQuTIh2IqHWFLy2TEjxr0MOK+5BSG4RzL2564AAPpZkDRsZXAUz68kjnU6Ug==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.0.tgz", + "integrity": "sha512-ZYvmh7VfVgqR/7wR71I3Zl6hK/C5CcxdWYKZSpHawS5JCNgE4efhQWg/+/WPpgGAp9Ngp/rRZYyaIwmPQBq/lA==", "dev": true, "license": "MIT", "peerDependencies": { - "eslint": ">=8.40" + "eslint": ">=9" } }, "node_modules/eslint-scope": { @@ -4747,19 +4766,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -4925,36 +4931,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4969,16 +4945,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -5052,14 +5018,16 @@ "dev": true }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -5120,6 +5088,31 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -5139,6 +5132,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -5190,11 +5197,10 @@ } }, "node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -5202,6 +5208,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -5209,13 +5228,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -5247,6 +5259,35 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5372,9 +5413,9 @@ } }, "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", "license": "MIT", "funding": { "type": "opencollective", @@ -6622,9 +6663,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -6804,9 +6845,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.memoize": { @@ -6842,10 +6883,10 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "license": "ISC", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -6905,6 +6946,16 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6913,16 +6964,6 @@ "license": "MIT", "peer": true }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7630,9 +7671,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -7646,9 +7687,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -7754,52 +7795,31 @@ "license": "MIT", "peer": true }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", - "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", - "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.1" + "react": "^19.2.4" } }, "node_modules/react-infinite-scroll-component": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", - "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.1.tgz", + "integrity": "sha512-R8YoOyiNDynSWmfVme5LHslsKrP+/xcRUWR2ies8UgUab9dtyw5ECnMCVPPmnmjjF4MWQmfVdRwRWcWaDgeyMA==", "license": "MIT", "dependencies": { "throttle-debounce": "^2.1.0" @@ -7905,9 +7925,9 @@ } }, "node_modules/react-router": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", - "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -7927,12 +7947,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz", - "integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "license": "MIT", "dependencies": { - "react-router": "7.9.3" + "react-router": "7.13.0" }, "engines": { "node": ">=20.0.0" @@ -8110,17 +8130,6 @@ "node": ">=10" } }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { "version": "4.34.9", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", @@ -8167,30 +8176,6 @@ "dev": true, "license": "MIT" }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8226,17 +8211,15 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -8245,15 +8228,15 @@ } }, "node_modules/serialize-query-params": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-2.0.2.tgz", - "integrity": "sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-2.0.4.tgz", + "integrity": "sha512-y9WzzDj3BsGgKLCh0ugiinufS//YqOfao/yVJjkXA4VLuyNCfHOLU/cbulGPxs3aeCqhvROw7qPL04JSZnCo0w==", "license": "ISC" }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/shebang-command": { @@ -8568,9 +8551,9 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8584,9 +8567,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==" }, "node_modules/test-exclude": { "version": "6.0.0", @@ -8770,9 +8753,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -8783,9 +8766,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.4", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz", - "integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==", + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { @@ -8795,7 +8778,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.2", + "semver": "^7.7.3", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -9038,12 +9021,12 @@ } }, "node_modules/use-query-params": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-2.2.1.tgz", - "integrity": "sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-2.2.2.tgz", + "integrity": "sha512-OwGab8u8/x2xZp9uSyBsx0kXlkR9IR436zbygsYVGikPYY3OJosvve6IJVGwIJPcfyb/YHwvPrUNu65/JR++Kw==", "license": "ISC", "dependencies": { - "serialize-query-params": "^2.0.2" + "serialize-query-params": "^2.0.3" }, "peerDependencies": { "@reach/router": "^1.2.1", @@ -9114,11 +9097,10 @@ } }, "node_modules/vite": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", - "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, - "license": "MIT", "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/web/ui/package.json b/web/ui/package.json index 4f4372a745..172e646aeb 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -1,7 +1,7 @@ { "name": "prometheus-io", "description": "Monorepo for the Prometheus UI", - "version": "0.307.3", + "version": "0.309.1", "private": true, "scripts": { "build": "bash build_ui.sh --all", @@ -16,12 +16,12 @@ ], "devDependencies": { "@types/jest": "^29.5.14", - "@typescript-eslint/eslint-plugin": "^8.45.0", - "@typescript-eslint/parser": "^8.45.0", + "@typescript-eslint/eslint-plugin": "^8.54.0", + "@typescript-eslint/parser": "^8.54.0", "eslint-config-prettier": "^10.1.8", - "prettier": "^3.6.2", - "ts-jest": "^29.4.4", + "prettier": "^3.8.1", + "ts-jest": "^29.4.6", "typescript": "^5.9.3", - "vite": "^6.3.6" + "vite": "^6.4.1" } } diff --git a/web/ui/react-app/src/globals.ts b/web/ui/react-app/src/globals.ts index d2a5f1d50a..7a59bdbffd 100644 --- a/web/ui/react-app/src/globals.ts +++ b/web/ui/react-app/src/globals.ts @@ -1,6 +1,5 @@ import jquery from 'jquery'; +import moment from 'moment'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(window as any).jQuery = jquery; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(window as any).moment = require('moment'); +window.jQuery = jquery; +window.moment = moment; diff --git a/web/ui/react-app/src/pages/graph/HistogramChart.test.tsx b/web/ui/react-app/src/pages/graph/HistogramChart.test.tsx index 27018c50ca..e9529282b1 100644 --- a/web/ui/react-app/src/pages/graph/HistogramChart.test.tsx +++ b/web/ui/react-app/src/pages/graph/HistogramChart.test.tsx @@ -68,7 +68,6 @@ describe('HistogramChart', () => { scale: 'linear' as 'linear' | 'exponential', }; - beforeEach(() => { mockFormat.mockClear(); mockResolvedOptions.mockClear(); @@ -163,7 +162,9 @@ describe('HistogramChart', () => { describe('Exponential Scale', () => { beforeEach(() => { - wrapper = mount(); + wrapper = mount( + + ); }); it('renders the correct number of buckets', () => { @@ -225,17 +226,24 @@ describe('HistogramChart', () => { expect(b4.find('.histogram-bucket').prop('style')).toHaveProperty('height', `${b4Height}%`); expect(parseFloat(b4.prop('style')?.left as string)).toBeGreaterThan(0); expect(parseFloat(b4.prop('style')?.width as string)).toBeGreaterThan(0); - expect(parseFloat(b4.prop('style')?.left as string) + parseFloat(b4.prop('style')?.width as string)).toBeLessThanOrEqual(100.01); + expect( + parseFloat(b4.prop('style')?.left as string) + parseFloat(b4.prop('style')?.width as string) + ).toBeLessThanOrEqual(100.01); }); it('handles zero-crossing bucket correctly in exponential scale', () => { - wrapper = mount(); + wrapper = mount( + + ); const buckets = wrapper.find('.histogram-bucket-slot'); const countMax = 15; const b2 = buckets.at(1); const b2Height = (5 / countMax) * 100; - expect(b2.find('.histogram-bucket').prop('style')).toHaveProperty('height', expect.stringContaining(b2Height.toFixed(1))); + expect(b2.find('.histogram-bucket').prop('style')).toHaveProperty( + 'height', + expect.stringContaining(b2Height.toFixed(1)) + ); expect(parseFloat(b2.prop('style')?.left as string)).toBeGreaterThanOrEqual(0); expect(parseFloat(b2.prop('style')?.width as string)).toBeGreaterThan(0); }); diff --git a/web/ui/react-app/src/pages/graph/HistorgramHelpers.test.tsx b/web/ui/react-app/src/pages/graph/HistorgramHelpers.test.tsx index ea70a17d08..480fb3716f 100644 --- a/web/ui/react-app/src/pages/graph/HistorgramHelpers.test.tsx +++ b/web/ui/react-app/src/pages/graph/HistorgramHelpers.test.tsx @@ -37,34 +37,31 @@ describe('HistogramHelpers', () => { ]; const bucketsStartingWithZeroCross: Bucket[] = [ - [0, '-1', '1', '5'], - [0, '1', '10', '20'], - [0, '10', '100', '8'], + [0, '-1', '1', '5'], + [0, '1', '10', '20'], + [0, '10', '100', '8'], ]; - const bucketsEndingWithZeroCross: Bucket[] = [ - [0, '-100', '-10', '10'], - [0, '-10', '-1', '15'], - [0, '-1', '1', '5'], + const bucketsEndingWithZeroCross: Bucket[] = [ + [0, '-100', '-10', '10'], + [0, '-10', '-1', '15'], + [0, '-1', '1', '5'], ]; - const singleZeroBucket: Bucket[] = [ - [0, '0', '0', '10'], - ]; + const singleZeroBucket: Bucket[] = [[0, '0', '0', '10']]; - const emptyBuckets: Bucket[] = []; + const emptyBuckets: Bucket[] = []; - const bucketsWithZeroFallback: Bucket[] = [ - [0, '1', '10', '5'], - [0, '10', '100', '15'], - [0, '0', '0', '2'] - ]; - - const bucketsNegThenPosNoCross: Bucket[] = [ - [0, '-10', '-1', '15'], - [0, '5', '10', '20'], - ]; + const bucketsWithZeroFallback: Bucket[] = [ + [0, '1', '10', '5'], + [0, '10', '100', '15'], + [0, '0', '0', '2'], + ]; + const bucketsNegThenPosNoCross: Bucket[] = [ + [0, '-10', '-1', '15'], + [0, '5', '10', '20'], + ]; describe('calculateDefaultExpBucketWidth', () => { it('calculates width for a standard positive bucket', () => { @@ -75,29 +72,30 @@ describe('HistogramHelpers', () => { it('calculates width for a standard negative bucket', () => { const lastBucket = bucketsAllNegative[bucketsAllNegative.length - 1]; - const expectedAbs = Math.abs(Math.log(Math.abs(parseFloat(lastBucket[2]))) - Math.log(Math.abs(parseFloat(lastBucket[1])))); + const expectedAbs = Math.abs( + Math.log(Math.abs(parseFloat(lastBucket[2]))) - Math.log(Math.abs(parseFloat(lastBucket[1]))) + ); expect(calculateDefaultExpBucketWidth(lastBucket, bucketsAllNegative)).toBeCloseTo(expectedAbs); }); it('uses the previous bucket if the last bucket is [0, 0]', () => { - const lastBucket = bucketsWithZeroFallback[bucketsWithZeroFallback.length - 1]; - const expected = Math.log(100) - Math.log(10); - expect(calculateDefaultExpBucketWidth(lastBucket, bucketsWithZeroFallback)).toBeCloseTo(expected); + const lastBucket = bucketsWithZeroFallback[bucketsWithZeroFallback.length - 1]; + const expected = Math.log(100) - Math.log(10); + expect(calculateDefaultExpBucketWidth(lastBucket, bucketsWithZeroFallback)).toBeCloseTo(expected); }); it('throws an error if only a single [0, 0] bucket exists', () => { - const lastBucket = singleZeroBucket[0]; - expect(() => calculateDefaultExpBucketWidth(lastBucket, singleZeroBucket)).toThrow( - 'Only one bucket in histogram ([-0, 0]). Cannot calculate defaultExpBucketWidth.' - ); + const lastBucket = singleZeroBucket[0]; + expect(() => calculateDefaultExpBucketWidth(lastBucket, singleZeroBucket)).toThrow( + 'Only one bucket in histogram ([-0, 0]). Cannot calculate defaultExpBucketWidth.' + ); }); }); - describe('findMinPositive', () => { - it('returns the first positive left bound when all are positive', () => { - expect(findMinPositive(bucketsAllPositive)).toEqual(1); - }); + it('returns the first positive left bound when all are positive', () => { + expect(findMinPositive(bucketsAllPositive)).toEqual(1); + }); it('returns the left bound when it is the first positive value', () => { expect(findMinPositive(bucketsNegThenPosNoCross)).toBe(5); @@ -108,43 +106,42 @@ describe('HistogramHelpers', () => { }); it('returns the right bound when the first bucket crosses zero', () => { - expect(findMinPositive(bucketsStartingWithZeroCross)).toBe(1); + expect(findMinPositive(bucketsStartingWithZeroCross)).toBe(1); }); it('returns the right bound when the last bucket crosses zero', () => { expect(findMinPositive(bucketsEndingWithZeroCross)).toBe(1); }); - it('returns 0 when all buckets are negative', () => { - expect(findMinPositive(bucketsAllNegative)).toBe(0); - }); + it('returns 0 when all buckets are negative', () => { + expect(findMinPositive(bucketsAllNegative)).toBe(0); + }); it('returns 0 for empty buckets', () => { expect(findMinPositive(emptyBuckets)).toBe(0); }); - it('returns 0 for only zero bucket', () => { - expect(findMinPositive(singleZeroBucket)).toBe(0); - }); + it('returns 0 for only zero bucket', () => { + expect(findMinPositive(singleZeroBucket)).toBe(0); + }); it('returns 0 when buckets is undefined', () => { expect(findMinPositive(undefined as any)).toBe(0); }); - it('returns the correct positive bound with exact zero bucket present', () => { - expect(findMinPositive(bucketsWithExactZeroBucket)).toBe(1); - }); + it('returns the correct positive bound with exact zero bucket present', () => { + expect(findMinPositive(bucketsWithExactZeroBucket)).toBe(1); + }); }); - describe('findMaxNegative', () => { - it('returns 0 when all buckets are positive', () => { - expect(findMaxNegative(bucketsAllPositive)).toBe(0); - }); + it('returns 0 when all buckets are positive', () => { + expect(findMaxNegative(bucketsAllPositive)).toBe(0); + }); - it('returns the right bound of the last negative bucket when all are negative', () => { - expect(findMaxNegative(bucketsAllNegative)).toEqual(-1); - }); + it('returns the right bound of the last negative bucket when all are negative', () => { + expect(findMaxNegative(bucketsAllNegative)).toEqual(-1); + }); it('returns the right bound of the bucket before the middle zero-crossing bucket', () => { expect(findMaxNegative(bucketsCrossingZeroMid)).toEqual(-1); @@ -155,7 +152,7 @@ describe('HistogramHelpers', () => { }); it('returns the right bound of the bucket before the last zero-crossing bucket', () => { - expect(findMaxNegative(bucketsEndingWithZeroCross)).toEqual(-1); + expect(findMaxNegative(bucketsEndingWithZeroCross)).toEqual(-1); }); it('returns 0 for empty buckets', () => { @@ -171,23 +168,28 @@ describe('HistogramHelpers', () => { }); it('returns the right bound of the bucket before an exact zero bucket', () => { - expect(findMaxNegative(bucketsWithExactZeroBucket)).toEqual(-1); + expect(findMaxNegative(bucketsWithExactZeroBucket)).toEqual(-1); }); }); - describe('findZeroBucket', () => { it('returns the index of bucket strictly containing zero', () => { expect(findZeroBucket(bucketsCrossingZeroMid)).toBe(2); }); it('returns the index of bucket with zero as left boundary', () => { - const buckets: Bucket[] = [[0, '-5','-1', '10'], [0, '0', '5', '15']]; + const buckets: Bucket[] = [ + [0, '-5', '-1', '10'], + [0, '0', '5', '15'], + ]; expect(findZeroBucket(buckets)).toBe(1); }); it('returns the index of bucket with zero as right boundary', () => { - const buckets: Bucket[] = [[0, '-5', '0', '10'], [0, '1', '5', '15']]; + const buckets: Bucket[] = [ + [0, '-5', '0', '10'], + [0, '1', '5', '15'], + ]; expect(findZeroBucket(buckets)).toBe(0); }); @@ -208,49 +210,51 @@ describe('HistogramHelpers', () => { }); it('returns 0 if the first bucket crosses zero', () => { - expect(findZeroBucket(bucketsStartingWithZeroCross)).toBe(0); + expect(findZeroBucket(bucketsStartingWithZeroCross)).toBe(0); }); - it('returns the last index if the last bucket crosses zero', () => { - expect(findZeroBucket(bucketsEndingWithZeroCross)).toBe(2); - }); + it('returns the last index if the last bucket crosses zero', () => { + expect(findZeroBucket(bucketsEndingWithZeroCross)).toBe(2); + }); it('returns -1 when buckets array is empty', () => { expect(findZeroBucket(emptyBuckets)).toBe(-1); }); }); - describe('findZeroAxisLeft', () => { it('calculates correctly for linear scale crossing zero', () => { - const rangeMin = -100; const rangeMax = 100; + const rangeMin = -100; + const rangeMax = 100; const expected = '50%'; const result = findZeroAxisLeft('linear', rangeMin, rangeMax, 1, -1, 2, 0, 0, 0); expect(result).toEqual(expected); }); - it('calculates correctly for asymmetric linear scale crossing zero', () => { - const rangeMin = -10; const rangeMax = 90; - const expectedNumber = ((0 - rangeMin) / (rangeMax - rangeMin)) * 100; - const resultString = findZeroAxisLeft('linear', rangeMin, rangeMax, 1, -1, 0, 0, 0, 0); - expect(parseFloat(resultString)).toBeCloseTo(expectedNumber, 1); - }); + it('calculates correctly for asymmetric linear scale crossing zero', () => { + const rangeMin = -10; + const rangeMax = 90; + const expectedNumber = ((0 - rangeMin) / (rangeMax - rangeMin)) * 100; + const resultString = findZeroAxisLeft('linear', rangeMin, rangeMax, 1, -1, 0, 0, 0, 0); + expect(parseFloat(resultString)).toBeCloseTo(expectedNumber, 1); + }); it('calculates correctly for linear scale all positive (off-scale left)', () => { - const rangeMin = 10; const rangeMax = 100; + const rangeMin = 10; + const rangeMax = 100; const expectedNumber = ((0 - rangeMin) / (rangeMax - rangeMin)) * 100; const resultString = findZeroAxisLeft('linear', rangeMin, rangeMax, 10, 0, -1, 0, 0, 0); expect(parseFloat(resultString)).toBeCloseTo(expectedNumber, 1); }); it('calculates correctly for linear scale all negative (off-scale right)', () => { - const rangeMin = -100; const rangeMax = -10; + const rangeMin = -100; + const rangeMax = -10; const expectedNumber = ((0 - rangeMin) / (rangeMax - rangeMin)) * 100; const resultString = findZeroAxisLeft('linear', rangeMin, rangeMax, 0, -10, -1, 0, 0, 0); expect(parseFloat(resultString)).toBeCloseTo(expectedNumber, 1); }); - const expMinPos = 1; const expMaxNeg = -1; const expZeroIdx = 2; @@ -264,22 +268,46 @@ describe('HistogramHelpers', () => { }); it('returns 100% for exponential scale when minPositive is 0', () => { - expect(findZeroAxisLeft('exponential', -100, -1, 0, -1, -1, expNegWidth, expNegWidth + defaultExpBW, defaultExpBW)).toEqual('100%'); + expect( + findZeroAxisLeft('exponential', -100, -1, 0, -1, -1, expNegWidth, expNegWidth + defaultExpBW, defaultExpBW) + ).toEqual('100%'); }); it('calculates position between buckets when zeroBucketIdx is -1 (exponential)', () => { - const minPos = 5; const maxNeg = -1; const zeroIdx = -1; + const minPos = 5; + const maxNeg = -1; + const zeroIdx = -1; const negW = Math.log(Math.abs(-1)) - Math.log(Math.abs(-10)); const posW = Math.log(10) - Math.log(5); const totalW = Math.abs(negW) + posW + defaultExpBW; const expectedNumber = (Math.abs(negW) / totalW) * 100; - const resultString = findZeroAxisLeft('exponential', -10, 10, minPos, maxNeg, zeroIdx, Math.abs(negW), totalW, defaultExpBW); + const resultString = findZeroAxisLeft( + 'exponential', + -10, + 10, + minPos, + maxNeg, + zeroIdx, + Math.abs(negW), + totalW, + defaultExpBW + ); expect(parseFloat(resultString)).toBeCloseTo(expectedNumber, 1); }); it('calculates position using bucket width when zeroBucketIdx exists (exponential)', () => { const expectedNumber = ((expNegWidth + 0.5 * defaultExpBW) / expTotalWidth) * 100; - const resultString = findZeroAxisLeft('exponential', -100, 100, expMinPos, expMaxNeg, expZeroIdx, expNegWidth, expTotalWidth, defaultExpBW); + const resultString = findZeroAxisLeft( + 'exponential', + -100, + 100, + expMinPos, + expMaxNeg, + expZeroIdx, + expNegWidth, + expTotalWidth, + defaultExpBW + ); expect(parseFloat(resultString)).toBeCloseTo(expectedNumber, 1); }); @@ -288,7 +316,6 @@ describe('HistogramHelpers', () => { }); }); - describe('showZeroAxis', () => { it('returns true when axis is between 5% and 95%', () => { expect(showZeroAxis('5.01%')).toBe(true); @@ -308,4 +335,4 @@ describe('HistogramHelpers', () => { expect(showZeroAxis('120%')).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/web/ui/react-app/src/types/index.d.ts b/web/ui/react-app/src/types/index.d.ts index addf1cc702..9cf8fbd7cc 100644 --- a/web/ui/react-app/src/types/index.d.ts +++ b/web/ui/react-app/src/types/index.d.ts @@ -68,3 +68,8 @@ interface JQueryStatic { scale: () => Color; }; } + +interface Window { + jQuery: JQueryStatic; + moment: typeof import('moment'); +} diff --git a/web/ui/react-app/src/utils/utils.test.ts b/web/ui/react-app/src/utils/utils.test.ts index 93174df87b..61fcd733ab 100644 --- a/web/ui/react-app/src/utils/utils.test.ts +++ b/web/ui/react-app/src/utils/utils.test.ts @@ -333,13 +333,13 @@ describe('Utils', () => { expect(parsePrometheusFloat('-1.7e+01')).toEqual(-17); }); }); - describe('createExpressionLink',()=>{ - it('<....>builds link',()=>{ + describe('createExpressionLink', () => { + it('<....>builds link', () => { expect(createExpressionLink('up')).toEqual( `../graph?g0.expr=up&g0.tab=1&g0.display_mode=${GraphDisplayMode.Lines}&g0.show_exemplars=0&g0.range_input=1h` ); }); - it('url-encodes PromQL',() =>{ + it('url-encodes PromQL', () => { expect(createExpressionLink('ALERTS{alertname="HighCPU"}')).toEqual( `../graph?g0.expr=ALERTS%7Balertname%3D%22High%20CPU%22%7D&g0.tab=1&g0.display_mode=${GraphDisplayMode.Lines}&g0.show_exemplars=0&g0.range_input=1h` ); diff --git a/web/ui/ui.go b/web/ui/ui.go index 2585951d4d..c427dcf119 100644 --- a/web/ui/ui.go +++ b/web/ui/ui.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at diff --git a/web/web.go b/web/web.go index 2d353a8af8..583492abc9 100644 --- a/web/web.go +++ b/web/web.go @@ -1,4 +1,4 @@ -// Copyright 2013 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -36,6 +36,7 @@ import ( "time" "github.com/alecthomas/units" + "github.com/felixge/fgprof" "github.com/grafana/regexp" "github.com/mwitkow/go-conntrack" remoteapi "github.com/prometheus/client_golang/exp/api/remote" @@ -53,10 +54,12 @@ import ( "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/notifier" "github.com/prometheus/prometheus/promql" + "github.com/prometheus/prometheus/promql/parser" "github.com/prometheus/prometheus/rules" "github.com/prometheus/prometheus/scrape" "github.com/prometheus/prometheus/storage" "github.com/prometheus/prometheus/template" + "github.com/prometheus/prometheus/util/features" "github.com/prometheus/prometheus/util/httputil" "github.com/prometheus/prometheus/util/netconnlimit" "github.com/prometheus/prometheus/util/notifications" @@ -111,6 +114,8 @@ const ( Stopping ) +var fgprofHandler = fgprof.Handler() + // withStackTracer logs the stack trace in case the request panics. The function // will re-raise the error which will then be handled by the net/http package. // It is needed because the go-kit log package doesn't manage properly the @@ -293,15 +298,19 @@ type Options struct { ConvertOTLPDelta bool NativeOTLPDeltaIngestion bool IsAgent bool - CTZeroIngestionEnabled bool + STZeroIngestionEnabled bool EnableTypeAndUnitLabels bool AppendMetadata bool AppName string AcceptRemoteWriteProtoMsgs remoteapi.MessageTypes - Gatherer prometheus.Gatherer - Registerer prometheus.Registerer + Gatherer prometheus.Gatherer + Registerer prometheus.Registerer + FeatureRegistry features.Collector + + // Parser is the PromQL parser used for parsing query expressions. + Parser parser.Parser } // New initializes a new web Handler. @@ -309,6 +318,9 @@ func New(logger *slog.Logger, o *Options) *Handler { if logger == nil { logger = promslog.NewNopLogger() } + if o.Parser == nil { + o.Parser = parser.NewParser(parser.Options{}) + } m := newMetrics(o.Registerer) router := route.New(). @@ -354,12 +366,20 @@ func New(logger *slog.Logger, o *Options) *Handler { factoryAr := func(context.Context) api_v1.AlertmanagerRetriever { return h.notifier } FactoryRr := func(context.Context) api_v1.RulesRetriever { return h.ruleManager } - var app storage.Appendable + var ( + app storage.Appendable + appV2 storage.AppendableV2 + ) if o.EnableRemoteWriteReceiver || o.EnableOTLPWriteReceiver { - app = h.storage + app, appV2 = h.storage, h.storage } - h.apiV1 = api_v1.NewAPI(h.queryEngine, h.storage, app, h.exemplarStorage, factorySPr, factoryTr, factoryAr, + version := "" + if o.Version != nil { + version = o.Version.Version + } + + h.apiV1 = api_v1.NewAPI(h.queryEngine, h.storage, app, appV2, h.exemplarStorage, factorySPr, factoryTr, factoryAr, func() config.Config { h.mtx.RLock() defer h.mtx.RUnlock() @@ -394,13 +414,39 @@ func New(logger *slog.Logger, o *Options) *Handler { o.EnableOTLPWriteReceiver, o.ConvertOTLPDelta, o.NativeOTLPDeltaIngestion, - o.CTZeroIngestionEnabled, + o.STZeroIngestionEnabled, o.LookbackDelta, o.EnableTypeAndUnitLabels, o.AppendMetadata, nil, + o.FeatureRegistry, + api_v1.OpenAPIOptions{ + ExternalURL: o.ExternalURL.String(), + Version: version, + }, + o.Parser, ) + if r := o.FeatureRegistry; r != nil { + // Set dynamic API features (based on configuration). + r.Set(features.API, "lifecycle", o.EnableLifecycle) + r.Set(features.API, "admin", o.EnableAdminAPI) + r.Set(features.API, "remote_write_receiver", o.EnableRemoteWriteReceiver) + r.Set(features.API, "otlp_write_receiver", o.EnableOTLPWriteReceiver) + r.Set(features.OTLPReceiver, "delta_conversion", o.ConvertOTLPDelta) + r.Set(features.OTLPReceiver, "native_delta_ingestion", o.NativeOTLPDeltaIngestion) + r.Enable(features.API, "label_values_match") // match[] parameter for label values endpoint. + r.Enable(features.API, "query_warnings") // warnings in query responses. + r.Enable(features.API, "query_stats") // stats parameter for query endpoints. + r.Enable(features.API, "time_range_series") // start/end parameters for /series endpoint. + r.Enable(features.API, "time_range_labels") // start/end parameters for /labels endpoints. + r.Enable(features.API, "exclude_alerts") // exclude_alerts parameter for /rules endpoint. + r.Enable(features.API, "openapi_3.1") // OpenAPI 3.1 specification support. + r.Enable(features.API, "openapi_3.2") // OpenAPI 3.2 specification support. + r.Set(features.UI, "ui_v3", !o.UseOldUI) + r.Set(features.UI, "ui_v2", o.UseOldUI) + } + if o.RoutePrefix != "/" { // If the prefix is missing for the root path, prepend it. router.Get("/", func(w http.ResponseWriter, r *http.Request) { @@ -434,13 +480,6 @@ func New(logger *slog.Logger, o *Options) *Handler { reactAssetsRoot = "/static/react-app" } - // The console library examples at 'console_libraries/prom.lib' still depend on old asset files being served under `classic`. - router.Get("/classic/static/*filepath", func(w http.ResponseWriter, r *http.Request) { - r.URL.Path = path.Join("/static", route.Param(r.Context(), "filepath")) - fs := server.StaticFileServer(ui.Assets) - fs.ServeHTTP(w, r) - }) - router.Get("/version", h.version) router.Get("/metrics", promhttp.Handler().ServeHTTP) @@ -590,6 +629,8 @@ func serveDebug(w http.ResponseWriter, req *http.Request) { pprof.Symbol(w, req) case "trace": pprof.Trace(w, req) + case "fgprof": + fgprofHandler.ServeHTTP(w, req) default: req.URL.Path = "/debug/pprof/" + subpath pprof.Index(w, req) @@ -620,8 +661,8 @@ func (h *Handler) testReady(f http.HandlerFunc) http.HandlerFunc { case Ready: f(w, r) case NotReady: - w.WriteHeader(http.StatusServiceUnavailable) w.Header().Set("X-Prometheus-Stopping", "false") + w.WriteHeader(http.StatusServiceUnavailable) fmt.Fprintf(w, "Service Unavailable") case Stopping: w.Header().Set("X-Prometheus-Stopping", "true") diff --git a/web/web_test.go b/web/web_test.go index b07e26cfa8..5ead252cbe 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -1,4 +1,4 @@ -// Copyright 2016 The Prometheus Authors +// Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -118,12 +118,10 @@ func TestReadyAndHealthy(t *testing.T) { } }() - // Give some time for the web goroutine to run since we need the server - // to be up before starting tests. - time.Sleep(5 * time.Second) - baseURL := "http://localhost" + port + waitForServerReady(t, baseURL, 5*time.Second) + resp, err := http.Get(baseURL + "/-/healthy") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -140,11 +138,32 @@ func TestReadyAndHealthy(t *testing.T) { resp, err = http.Get(u) require.NoError(t, err) require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + require.Equal(t, "false", resp.Header.Get("X-Prometheus-Stopping")) cleanupTestResponse(t, resp) resp, err = http.Head(u) require.NoError(t, err) require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + require.Equal(t, "false", resp.Header.Get("X-Prometheus-Stopping")) + cleanupTestResponse(t, resp) + } + + // Set to stopping + webHandler.SetReady(Stopping) + + for _, u := range []string{ + baseURL + "/-/ready", + } { + resp, err = http.Get(u) + require.NoError(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + require.Equal(t, "true", resp.Header.Get("X-Prometheus-Stopping")) + cleanupTestResponse(t, resp) + + resp, err = http.Head(u) + require.NoError(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + require.Equal(t, "true", resp.Header.Get("X-Prometheus-Stopping")) cleanupTestResponse(t, resp) } @@ -235,12 +254,10 @@ func TestRoutePrefix(t *testing.T) { } }() - // Give some time for the web goroutine to run since we need the server - // to be up before starting tests. - time.Sleep(5 * time.Second) - baseURL := "http://localhost" + port + waitForServerReady(t, baseURL+opts.RoutePrefix, 5*time.Second) + resp, err := http.Get(baseURL + opts.RoutePrefix + "/-/healthy") require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -307,6 +324,7 @@ func TestDebugHandler(t *testing.T) { Host: "localhost.localdomain:9090", Scheme: "http", }, + Version: &PrometheusVersion{}, } handler := New(nil, opts) handler.SetReady(Ready) @@ -332,6 +350,7 @@ func TestHTTPMetrics(t *testing.T) { Host: "localhost.localdomain:9090", Scheme: "http", }, + Version: &PrometheusVersion{}, }) getReady := func() int { t.Helper() @@ -426,9 +445,9 @@ func TestShutdownWithStaleConnection(t *testing.T) { close(closed) }() - // Give some time for the web goroutine to run since we need the server - // to be up before starting tests. - time.Sleep(5 * time.Second) + baseURL := "http://localhost" + port + + waitForServerReady(t, baseURL, 5*time.Second) // Open a socket, and don't use it. This connection should then be closed // after the ReadTimeout. @@ -477,23 +496,19 @@ func TestHandleMultipleQuitRequests(t *testing.T) { close(closed) }() - // Give some time for the web goroutine to run since we need the server - // to be up before starting tests. - time.Sleep(5 * time.Second) - baseURL := opts.ExternalURL.Scheme + "://" + opts.ExternalURL.Host + waitForServerReady(t, baseURL, 5*time.Second) + start := make(chan struct{}) var wg sync.WaitGroup for range 3 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { <-start resp, err := http.Post(baseURL+"/-/quit", "", strings.NewReader("")) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) - }() + }) } close(start) wg.Wait() @@ -555,11 +570,10 @@ func TestAgentAPIEndPoints(t *testing.T) { } }() - // Give some time for the web goroutine to run since we need the server - // to be up before starting tests. - time.Sleep(5 * time.Second) baseURL := "http://localhost" + port + "/api/v1" + waitForServerReady(t, "http://localhost"+port, 5*time.Second) + // Test for non-available endpoints in the Agent mode. for path, methods := range map[string][]string{ "/labels": {http.MethodGet, http.MethodPost}, @@ -688,9 +702,7 @@ func TestMultipleListenAddresses(t *testing.T) { } }() - // Give some time for the web goroutine to run since we need the server - // to be up before starting tests. - time.Sleep(5 * time.Second) + waitForServerReady(t, "http://localhost"+port1, 5*time.Second) // Set to ready. webHandler.SetReady(Ready) @@ -709,3 +721,24 @@ func TestMultipleListenAddresses(t *testing.T) { cleanupTestResponse(t, resp) } } + +// Give some time for the web goroutine to run since we need the server +// to be up before starting tests. +func waitForServerReady(t *testing.T, baseURL string, timeout time.Duration) { + t.Helper() + + interval := 100 * time.Millisecond + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + resp, err := http.Get(baseURL + "/-/healthy") + if resp != nil { + cleanupTestResponse(t, resp) + } + if err == nil && resp.StatusCode == http.StatusOK { + return + } + time.Sleep(interval) + } + t.Fatalf("Server did not become ready within %v", timeout) +}