├── requirements.txt ├── redbox ├── request │ ├── classes │ │ ├── __init__.py │ │ └── request.py │ ├── types │ │ └── __init__.py │ ├── __init__.py │ ├── conditions.py │ └── request_simple.py ├── config │ ├── __init__.py │ ├── types │ │ ├── __init__.py │ │ └── ds_config.py │ ├── config.py │ └── template.py ├── types │ ├── __init__.py │ ├── ds_target.py │ └── ds_response.py ├── args.py ├── defaults.py ├── prometheus.py └── __init__.py ├── bin ├── README.md └── redbox ├── example ├── volumes │ ├── grafana │ │ └── provisioning │ │ │ ├── datasources │ │ │ └── datasource.yml │ │ │ └── dashboards │ │ │ ├── dashboard.yml │ │ │ └── dash-redbox.json │ ├── prometheus │ │ └── prometheus.yml │ └── redbox │ │ └── conf.yml-example ├── README.md └── docker-compose.yml ├── Dockerfile ├── .editorconfig ├── .github └── workflows │ ├── code-black.yml │ ├── code-mypy.yml │ ├── code-pylint.yml │ ├── code-pydocstyle.yml │ ├── code-pycodestyle.yml │ ├── linting.yml │ └── building.yml ├── .gitignore ├── LICENSE.txt ├── setup.py ├── README.md ├── setup.cfg ├── etc └── config.yml └── Makefile /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML 2 | requests 3 | -------------------------------------------------------------------------------- /redbox/request/classes/__init__.py: -------------------------------------------------------------------------------- 1 | """Module Imports.""" 2 | 3 | from .request import Request 4 | -------------------------------------------------------------------------------- /redbox/config/__init__.py: -------------------------------------------------------------------------------- 1 | """Module Imports.""" 2 | 3 | from .types import * 4 | from .config import * 5 | -------------------------------------------------------------------------------- /redbox/config/types/__init__.py: -------------------------------------------------------------------------------- 1 | """Module Imports.""" 2 | 3 | from .ds_config import DsConfig 4 | from ...types import DsTarget 5 | -------------------------------------------------------------------------------- /redbox/request/types/__init__.py: -------------------------------------------------------------------------------- 1 | """Module Imports.""" 2 | 3 | from ...types import DsResponse 4 | from ...types import DsTarget 5 | -------------------------------------------------------------------------------- /redbox/types/__init__.py: -------------------------------------------------------------------------------- 1 | """Module Imports.""" 2 | 3 | from .ds_target import DsTarget 4 | from .ds_response import DsResponse 5 | -------------------------------------------------------------------------------- /redbox/request/__init__.py: -------------------------------------------------------------------------------- 1 | """Module Imports.""" 2 | 3 | from .types import DsTarget 4 | from .types import DsResponse 5 | from .classes import Request 6 | from .request_simple import RequestSimple 7 | -------------------------------------------------------------------------------- /bin/README.md: -------------------------------------------------------------------------------- 1 | # `bin/` directory 2 | 3 | 4 | This directory contains a Python wrapper to execute the code in `redbox/` package directory. 5 | 6 | * It is not the final produced artifact. 7 | * Do not copy this file somewhere else (it won't work) 8 | * Use `setup.py` or `pip` for installation 9 | -------------------------------------------------------------------------------- /example/volumes/grafana/provisioning/datasources/datasource.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: 1 3 | 4 | datasources: 5 | - name: Prometheus 6 | type: prometheus 7 | access: proxy 8 | orgId: 1 9 | url: http://prometheus:9090 10 | basicAuth: false 11 | isDefault: true 12 | editable: true 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | COPY requirements.txt /tmp/requirements.txt 4 | COPY README.md /tmp/README.md 5 | COPY setup.py /tmp/setup.py 6 | COPY redbox /tmp/redbox 7 | 8 | RUN set -eux \ 9 | && cd /tmp \ 10 | && python setup.py install 11 | 12 | ENTRYPOINT ["/usr/local/bin/redbox_exporter"] 13 | -------------------------------------------------------------------------------- /example/volumes/grafana/provisioning/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: 1 3 | 4 | providers: 5 | - name: 'Prometheus' 6 | orgId: 1 7 | folder: '' 8 | type: file 9 | disableDeletion: false 10 | editable: true 11 | allowUiUpdates: true 12 | options: 13 | path: /etc/grafana/provisioning/dashboards 14 | -------------------------------------------------------------------------------- /bin/redbox: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import pathlib 6 | 7 | # Get absolute path of root of this git directory 8 | path_here = pathlib.Path(__file__).parent.absolute() 9 | path_root = os.path.join(path_here, os.pardir) 10 | sys.path.append(path_root) 11 | 12 | # Import our package 13 | import redbox 14 | 15 | 16 | # Run main function 17 | redbox.main() 18 | -------------------------------------------------------------------------------- /example/volumes/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Global settings 3 | global: 4 | scrape_interval: 60s 5 | scrape_timeout: 60s 6 | evaluation_interval: 60s 7 | 8 | # Scrape specific settings 9 | scrape_configs: 10 | - job_name: redbox 11 | honor_timestamps: true 12 | scrape_interval: 30s 13 | scrape_timeout: 30s 14 | metrics_path: /metrics 15 | scheme: http 16 | static_configs: 17 | - targets: ["redbox_exporter:9100"] 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Default for all files 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | # Custom files 12 | [*.py] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | indent_size = 4 19 | 20 | [*.{yml,yaml}] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.md] 25 | indent_style = space 26 | trim_trailing_whitespace = false 27 | indent_size = 2 28 | -------------------------------------------------------------------------------- /.github/workflows/code-black.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### 4 | ### Code style 5 | ### 6 | 7 | name: black 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: False 20 | matrix: 21 | target: 22 | - black 23 | name: "[ ${{ matrix.target }} ]" 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@master 27 | 28 | - name: "${{ matrix.target }}" 29 | run: | 30 | make _code-${{ matrix.target }} 31 | -------------------------------------------------------------------------------- /.github/workflows/code-mypy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### 4 | ### Code style 5 | ### 6 | 7 | name: mypy 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: False 20 | matrix: 21 | target: 22 | - mypy 23 | name: "[ ${{ matrix.target }} ]" 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@master 27 | 28 | - name: "${{ matrix.target }}" 29 | run: | 30 | make _code-${{ matrix.target }} 31 | -------------------------------------------------------------------------------- /.github/workflows/code-pylint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### 4 | ### Code style 5 | ### 6 | 7 | name: pylint 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: False 20 | matrix: 21 | target: 22 | - pylint 23 | name: "[ ${{ matrix.target }} ]" 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@master 27 | 28 | - name: "${{ matrix.target }}" 29 | run: | 30 | make _code-${{ matrix.target }} 31 | -------------------------------------------------------------------------------- /.github/workflows/code-pydocstyle.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### 4 | ### Code style 5 | ### 6 | 7 | name: pydoc 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: False 20 | matrix: 21 | target: 22 | - pydocstyle 23 | name: "[ ${{ matrix.target }} ]" 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@master 27 | 28 | - name: "${{ matrix.target }}" 29 | run: | 30 | make _code-${{ matrix.target }} 31 | -------------------------------------------------------------------------------- /.github/workflows/code-pycodestyle.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### 4 | ### Code style 5 | ### 6 | 7 | name: pycode 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: False 20 | matrix: 21 | target: 22 | - pycodestyle 23 | name: "[ ${{ matrix.target }} ]" 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@master 27 | 28 | - name: "${{ matrix.target }}" 29 | run: | 30 | make _code-${{ matrix.target }} 31 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example usage 2 | 3 | This directory contains a fully dockerized (Docker Compose) ready-to-go setup with **Prometheus**, **Grafana** and the **redbox** exporter. 4 | 5 | 6 | ## 1. Create `redbox` config 7 | 8 | * Navigate to `volumes/redbox/` 9 | * Copy `conf.yml-example` to `conf.yml` 10 | * Populate `conf.yml` as needed 11 | 12 | 13 | ## 2. Build image 14 | ```bash 15 | docker-compose build 16 | ``` 17 | 18 | 19 | ## 3. Start setup 20 | ```bash 21 | docker-compose up 22 | ``` 23 | 24 | 25 | ## 4. View Dashoard 26 | 27 | Open Grafana 28 | * URL: http://localhost:3000 29 | * User: admin 30 | * Pass: admin 31 | 32 | Open Pre-build `redbox` dashboard 33 | * Click on `Search` on the left menu 34 | * Click on `HTTP status` under general folder 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------------------------- 2 | # Python cache 3 | # -------------------------------------------------------------------------------------------------- 4 | __pycache__ 5 | *.pyc 6 | 7 | 8 | # -------------------------------------------------------------------------------------------------- 9 | # Build artifacts 10 | # -------------------------------------------------------------------------------------------------- 11 | .mypy_cache/* 12 | prometheus_redbox_exporter.egg-info/ 13 | env/ 14 | build/ 15 | dist/ 16 | 17 | 18 | # -------------------------------------------------------------------------------------------------- 19 | # Example artifacts 20 | # -------------------------------------------------------------------------------------------------- 21 | example/volumes/redbox/conf.yml 22 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### 4 | ### Lints all generic and json files in the whole git repository 5 | ### 6 | 7 | name: linting 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | 15 | jobs: 16 | lint: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: False 20 | matrix: 21 | target: 22 | - Linting 23 | name: "[ ${{ matrix.target }} ]" 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@master 27 | 28 | - name: Lint files 29 | run: | 30 | make _lint-files 31 | 32 | - name: "Check equal version in setup.py and redbox/defaults.py" 33 | run: | 34 | make _lint-version 35 | 36 | - name: "Check equal name in setup.py and redbox/defaults.py" 37 | run: | 38 | make _lint-name 39 | 40 | - name: "Check equal description in setup.py and redbox/defaults.py" 41 | run: | 42 | make _lint-description 43 | 44 | 45 | -------------------------------------------------------------------------------- /redbox/config/types/ds_config.py: -------------------------------------------------------------------------------- 1 | """Datatype definition.""" 2 | 3 | from typing import List 4 | 5 | from ...types import DsTarget 6 | 7 | 8 | class DsConfig: 9 | """Datastructure for config.""" 10 | 11 | @property 12 | def scrape_timeout(self) -> int: 13 | """Scrape timeout.""" 14 | return self.__scrape_timeout 15 | 16 | @property 17 | def listen_addr(self) -> str: 18 | """Listen address.""" 19 | return self.__listen_addr 20 | 21 | @property 22 | def listen_port(self) -> int: 23 | """Listen port.""" 24 | return self.__listen_port 25 | 26 | @property 27 | def targets(self) -> List[DsTarget]: 28 | """List of targets to check.""" 29 | return self.__targets 30 | 31 | def __init__( 32 | self, scrape_timeout: int, listen_addr: str, listen_port: int, targets: List[DsTarget] 33 | ) -> None: 34 | self.__scrape_timeout = scrape_timeout 35 | self.__listen_addr = listen_addr 36 | self.__listen_port = listen_port 37 | self.__targets = targets 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 cytopia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/building.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ### 4 | ### Lints all generic and json files in the whole git repository 5 | ### 6 | 7 | name: building 8 | on: 9 | pull_request: 10 | push: 11 | branches: 12 | - master 13 | tags: 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: False 20 | matrix: 21 | version: 22 | - '3.5' 23 | - '3.6' 24 | - '3.7' 25 | - '3.8' 26 | 27 | name: "[${{ matrix.version }}]" 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@master 31 | 32 | - name: Build source distribution 33 | run: | 34 | make _build-source_dist PYTHON_VERSION=${version} 35 | env: 36 | version: ${{ matrix.version }} 37 | 38 | - name: Build binary distribution 39 | run: | 40 | make _build-binary_dist PYTHON_VERSION=${version} 41 | env: 42 | version: ${{ matrix.version }} 43 | 44 | - name: Build Python package 45 | run: | 46 | make _build-python_package PYTHON_VERSION=${version} 47 | env: 48 | version: ${{ matrix.version }} 49 | 50 | - name: Check Python package 51 | run: | 52 | make _build-check_python_package PYTHON_VERSION=${version} 53 | env: 54 | version: ${{ matrix.version }} 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Pip configuration.""" 2 | # https://github.com/pypa/sampleproject/blob/main/setup.py 3 | 4 | from setuptools import setup 5 | 6 | with open("README.md", "r") as fp: 7 | long_description = fp.read() 8 | 9 | with open("requirements.txt", "r") as fp: 10 | requirements = fp.read() 11 | 12 | setup( 13 | name="prometheus-redbox-exporter", 14 | python_requires='>3.5.2', 15 | version="0.1.3", 16 | packages=[ 17 | "redbox", 18 | "redbox.config", 19 | "redbox.config.types", 20 | "redbox.request", 21 | "redbox.request.classes", 22 | "redbox.request.types", 23 | "redbox.types", 24 | ], 25 | entry_points={ 26 | 'console_scripts': [ 27 | # cmd = package[.module]:func 28 | 'redbox_exporter=redbox:main', 29 | ], 30 | }, 31 | install_requires=requirements, 32 | description="Prometheus exporter that throws stuff to httpd endpoints and evaluates their response.", 33 | license="MIT", 34 | long_description=long_description, 35 | long_description_content_type="text/markdown", 36 | keywords=["prometheus", "redbox_exporter", "redbox", "blackbox", "blackbox_exporter"], 37 | author="cytopia", 38 | author_email="cytopia@everythingcli.org", 39 | url="https://github.com/cytopia/prometheus-redbox_exporter", 40 | classifiers=[ 41 | # https://pypi.org/classifiers/ 42 | # 43 | # License 44 | "License :: OSI Approved :: MIT License", 45 | # Specify the Python versions you support here. In particular, ensure 46 | # that you indicate whether you support Python 2, Python 3 or both. 47 | "Programming Language :: Python :: 3", 48 | ] 49 | ) 50 | -------------------------------------------------------------------------------- /redbox/request/conditions.py: -------------------------------------------------------------------------------- 1 | """Evaluate response based on conditions.""" 2 | 3 | from typing import Dict, Any 4 | 5 | from .types import DsTarget 6 | from .types import DsResponse 7 | 8 | 9 | # ------------------------------------------------------------------------------------------------- 10 | # Public Methods 11 | # ------------------------------------------------------------------------------------------------- 12 | def evaluate_response(target: DsTarget, response: DsResponse) -> DsResponse: 13 | """Evaluate response based on defined config ressings.""" 14 | # If it had already failed, we do not need to evaluate further 15 | if not response.success: 16 | return response 17 | 18 | # If we do not have any conditions defined, return as it is 19 | if not target.fail_if: 20 | return response 21 | 22 | # Evaluate 23 | response = __status_code_not_in(response, target.fail_if) 24 | return response 25 | 26 | 27 | # ------------------------------------------------------------------------------------------------- 28 | # Hidden Methods 29 | # ------------------------------------------------------------------------------------------------- 30 | def __status_code_not_in(response: DsResponse, fail_if: Dict[str, Any]) -> DsResponse: 31 | """Evaluate status_code_not_in.""" 32 | # Check status_code 33 | if "status_code_not_in" in fail_if: 34 | if fail_if["status_code_not_in"]: 35 | status_code = response.status_code 36 | status_list = [int(i) for i in fail_if["status_code_not_in"]] 37 | if status_code not in status_list: 38 | response.success = False 39 | response.err_msg = "Condition Error: Http status code {} not in: {}".format( 40 | str(status_code), ",".join([str(i) for i in fail_if["status_code_not_in"]]) 41 | ) 42 | return response 43 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '2.1' 3 | 4 | services: 5 | redbox_exporter: 6 | build: ../ 7 | volumes: 8 | - ./volumes/redbox/conf.yml:/etc/redbox/conf.yml 9 | command: 10 | - '-c/etc/redbox/conf.yml' 11 | - '-p9100' 12 | - '-l0.0.0.0' 13 | restart: unless-stopped 14 | expose: 15 | - 9100 16 | ports: 17 | - "9100:9100" 18 | networks: 19 | - monitor-net 20 | labels: 21 | org.label-schema.group: "monitoring" 22 | 23 | # https://hub.docker.com/r/prom/prometheus/tags?page=1&ordering=last_updated 24 | prometheus: 25 | image: prom/prometheus:v2.25.0 26 | container_name: prometheus 27 | volumes: 28 | - ./volumes/prometheus:/etc/prometheus 29 | - prometheus_data:/prometheus 30 | command: 31 | - '--config.file=/etc/prometheus/prometheus.yml' 32 | - '--storage.tsdb.path=/prometheus' 33 | - '--web.console.libraries=/etc/prometheus/console_libraries' 34 | - '--web.console.templates=/etc/prometheus/consoles' 35 | - '--storage.tsdb.retention.time=200h' 36 | - '--web.enable-lifecycle' 37 | restart: unless-stopped 38 | expose: 39 | - 9090 40 | ports: 41 | - "9090:9090" 42 | networks: 43 | - monitor-net 44 | labels: 45 | org.label-schema.group: "monitoring" 46 | 47 | # https://hub.docker.com/r/grafana/grafana/tags?page=1&ordering=last_updated 48 | grafana: 49 | image: grafana/grafana:7.4.2 50 | container_name: grafana 51 | volumes: 52 | - grafana_data:/var/lib/grafana 53 | - ./volumes/grafana/provisioning:/etc/grafana/provisioning 54 | environment: 55 | - GF_SECURITY_ADMIN_USER=admin 56 | - GF_SECURITY_ADMIN_PASSWORD=admin 57 | - GF_USERS_ALLOW_SIGN_UP=false 58 | restart: unless-stopped 59 | expose: 60 | - 3000 61 | ports: 62 | - "3000:3000" 63 | networks: 64 | - monitor-net 65 | labels: 66 | org.label-schema.group: "monitoring" 67 | 68 | networks: 69 | monitor-net: 70 | driver: bridge 71 | 72 | volumes: 73 | prometheus_data: {} 74 | grafana_data: {} 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prometheus redbox exporter 2 | 3 | [![](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 4 | [![PyPI](https://img.shields.io/pypi/v/prometheus-redbox-exporter)](https://pypi.org/project/prometheus-redbox-exporter/) 5 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/prometheus-redbox-exporter)](https://pypi.org/project/prometheus-redbox-exporter/) 6 | [![PyPI - Format](https://img.shields.io/pypi/format/prometheus-redbox-exporter)](https://pypi.org/project/prometheus-redbox-exporter/) 7 | [![PyPI - Implementation](https://img.shields.io/pypi/implementation/prometheus-redbox-exporter)](https://pypi.org/project/prometheus-redbox-exporter/) 8 | [![PyPI - License](https://img.shields.io/pypi/l/prometheus-redbox-exporter)](https://pypi.org/project/prometheus-redbox-exporter/) 9 | 10 | 11 | [![Build Status](https://github.com/cytopia/prometheus-redbox_exporter/workflows/linting/badge.svg)](https://github.com/cytopia/prometheus-redbox_exporter/actions?workflow=linting) 12 | [![Build Status](https://github.com/cytopia/prometheus-redbox_exporter/workflows/building/badge.svg)](https://github.com/cytopia/prometheus-redbox_exporter/actions?workflow=building) 13 | [![Build Status](https://github.com/cytopia/prometheus-redbox_exporter/workflows/black/badge.svg)](https://github.com/cytopia/prometheus-redbox_exporter/actions?workflow=black) 14 | [![Build Status](https://github.com/cytopia/prometheus-redbox_exporter/workflows/mypy/badge.svg)](https://github.com/cytopia/prometheus-redbox_exporter/actions?workflow=mypy) 15 | [![Build Status](https://github.com/cytopia/prometheus-redbox_exporter/workflows/pylint/badge.svg)](https://github.com/cytopia/prometheus-redbox_exporter/actions?workflow=pylint) 16 | [![Build Status](https://github.com/cytopia/prometheus-redbox_exporter/workflows/pycode/badge.svg)](https://github.com/cytopia/prometheus-redbox_exporter/actions?workflow=pycode) 17 | [![Build Status](https://github.com/cytopia/prometheus-redbox_exporter/workflows/pydoc/badge.svg)](https://github.com/cytopia/prometheus-redbox_exporter/actions?workflow=pydoc) 18 | 19 | 20 | ## Install 21 | ```bash 22 | pip install prometheus-redbox-exporter 23 | ``` 24 | 25 | 26 | ## Example 27 | 28 | [Click here for a fully functional Docker Compose example](example/) 29 | -------------------------------------------------------------------------------- /redbox/args.py: -------------------------------------------------------------------------------- 1 | """Parse command line arguments.""" 2 | 3 | import argparse 4 | 5 | from .defaults import DEF_NAME, DEF_DESC, DEF_VERSION, DEF_AUTHOR, DEF_GITHUB 6 | from .defaults import DEF_SRV_LISTEN_ADDR, DEF_SRV_LISTEN_PORT 7 | 8 | 9 | def _get_version(): 10 | # type: () -> str 11 | """Return version information.""" 12 | return """%(prog)s: Version %(version)s 13 | (%(url)s) by %(author)s""" % ( 14 | {"prog": DEF_NAME, "version": DEF_VERSION, "url": DEF_GITHUB, "author": DEF_AUTHOR} 15 | ) 16 | 17 | 18 | def get_args() -> argparse.Namespace: 19 | """Retrieve command line arguments.""" 20 | parser = argparse.ArgumentParser( 21 | formatter_class=argparse.RawTextHelpFormatter, 22 | add_help=False, 23 | usage="""%(prog)s [options] -c CONF 24 | %(prog)s -v, --version 25 | %(prog)s -h, --help""" 26 | % ({"prog": DEF_NAME}), 27 | description=DEF_DESC, 28 | ) 29 | required = parser.add_argument_group("required arguments") 30 | optional = parser.add_argument_group("optional arguments") 31 | misc = parser.add_argument_group("misc arguments") 32 | 33 | required.add_argument( 34 | "-c", "--conf", type=str, required=True, help="""Specify path to configuration file.""" 35 | ) 36 | optional.add_argument( 37 | "-l", 38 | "--listen", 39 | metavar="ADDR", 40 | type=str, 41 | default=DEF_SRV_LISTEN_ADDR, 42 | help="""Override listen address from configuration file. 43 | Defaults to {} if neither, cmd argument or config settings exist.""".format( 44 | DEF_SRV_LISTEN_ADDR 45 | ), 46 | ) 47 | optional.add_argument( 48 | "-p", 49 | "--port", 50 | type=int, 51 | default=DEF_SRV_LISTEN_PORT, 52 | help="""Override listen port from configuration file. 53 | Defaults to {} if neither, cmd argument or config settings exist.""".format( 54 | DEF_SRV_LISTEN_PORT 55 | ), 56 | ) 57 | misc.add_argument( 58 | "-v", 59 | "--version", 60 | action="version", 61 | version=_get_version(), 62 | help="Show version information and exit", 63 | ) 64 | misc.add_argument("-h", "--help", action="help", help="Show this help message and exit") 65 | 66 | # Return arguments 67 | return parser.parse_args() 68 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal=1 6 | 7 | # -------------------------------------------------------------------------------- 8 | # Linter 9 | # -------------------------------------------------------------------------------- 10 | [pycodestyle] 11 | max-line-length = 100 12 | statistics = True 13 | show-source = True 14 | show-pep8 = True 15 | 16 | [pydocstyle] 17 | convention = google 18 | # D107: Description is on the class level instead 19 | add_ignore = D107 20 | 21 | [flake8] 22 | max-line-length = 100 23 | 24 | [pylint] 25 | # useless-object-inheritance: don't lint useless-object-inheritance to stary Python2/3 compatible 26 | # bad-continuation: let Python Black take care of this 27 | # unidiomatic-typecheck: Need to check if int or bool and this doesnt work with isinstance() 28 | disable = useless-object-inheritance, bad-continuation, unidiomatic-typecheck, duplicate-code 29 | max-branches = 30 30 | max-statements = 121 31 | max-args = 15 32 | max-attributes = 15 33 | max-locals = 37 34 | max-module-lines = 7000 35 | max-bool-expr = 6 36 | max-returns = 11 37 | min-public-methods = 1 38 | max-nested-blocks = 7 39 | # List of note tags to take in consideration, separated by a comma. 40 | #notes=FIXME,TODO 41 | notes=FIXME 42 | 43 | [mypy] 44 | # Display 45 | show_error_context = True 46 | show_column_numbers = True 47 | show_error_codes = True 48 | pretty = True 49 | color_output = True 50 | error_summary = True 51 | 52 | # Meta 53 | warn_unused_configs = True 54 | incremental = False 55 | show_traceback = True 56 | 57 | # Mode 58 | strict_optional = True 59 | show_none_errors = True 60 | 61 | # Allow 62 | disallow_any_expr = False 63 | disallow_any_explicit = False 64 | disallow_any_decorated = False 65 | 66 | # Deny 67 | disallow_any_unimported = True 68 | disallow_any_generics = True 69 | disallow_subclassing_any = True 70 | disallow_untyped_calls = True 71 | disallow_untyped_defs = True 72 | disallow_incomplete_defs = True 73 | check_untyped_defs = True 74 | disallow_untyped_decorators = True 75 | warn_redundant_casts = True 76 | warn_unused_ignores = True 77 | warn_no_return = True 78 | warn_return_any = True 79 | warn_unreachable = True 80 | allow_untyped_globals = False 81 | allow_redefinition = False 82 | 83 | [bandit] 84 | # B101: asserts 85 | # B404: blacklist (this is an offensive tool overall) 86 | skips = B101,B404 87 | -------------------------------------------------------------------------------- /redbox/types/ds_target.py: -------------------------------------------------------------------------------- 1 | """Datatype definition.""" 2 | 3 | from typing import Dict, List, Union, Any 4 | 5 | 6 | class DsTarget: 7 | """Datastructure for target.""" 8 | 9 | @property 10 | def name(self) -> str: 11 | """Display name.""" 12 | return self.__name 13 | 14 | @property 15 | def groups(self) -> Dict[str, str]: 16 | """Custom groups of a target. E.g.: env, page, type...""" 17 | return self.__groups 18 | 19 | @property 20 | def url(self) -> str: 21 | """Url of the target.""" 22 | return self.__url 23 | 24 | @property 25 | def method(self) -> str: 26 | """Request method.""" 27 | return self.__method 28 | 29 | @property 30 | def params(self) -> Dict[str, str]: 31 | """Additional parameters to parse to the request.""" 32 | return self.__params 33 | 34 | @property 35 | def headers(self) -> Dict[str, str]: 36 | """Additional headers to parse to the request.""" 37 | return self.__headers 38 | 39 | @property 40 | def timeout(self) -> Union[int, float]: 41 | """Timeout to wait for the request to finish.""" 42 | return self.__timeout 43 | 44 | @property 45 | def basic_auth(self) -> Dict[str, str]: 46 | """Basic auth data.""" 47 | return self.__basic_auth 48 | 49 | @property 50 | def digest_auth(self) -> Dict[str, str]: 51 | """Digest auth data.""" 52 | return self.__digest_auth 53 | 54 | @property 55 | def fail_if(self) -> Dict[str, Any]: 56 | """Fail if conditions.""" 57 | return self.__fail_if 58 | 59 | @property 60 | def extract(self) -> Dict[str, List[str]]: 61 | """Extract regexes.""" 62 | return self.__extract 63 | 64 | def __init__(self, target: Dict[str, Any]) -> None: 65 | self.__name = str(target["name"]) 66 | self.__groups = dict(target["groups"]) 67 | self.__url = str(target["url"]) 68 | self.__method = str(target["method"]) 69 | self.__params = dict(target["params"]) 70 | self.__headers = dict(target["headers"]) 71 | self.__timeout = float(target["timeout"]) 72 | self.__basic_auth = dict(target["basic_auth"]) 73 | self.__digest_auth = dict(target["digest_auth"]) 74 | self.__fail_if = dict(target["fail_if"]) 75 | self.__extract = dict(target["extract"]) 76 | -------------------------------------------------------------------------------- /redbox/defaults.py: -------------------------------------------------------------------------------- 1 | """This file defines all module wide default values.""" 2 | 3 | # Credits 4 | DEF_NAME = "redbox_exporter" 5 | DEF_DESC = "Prometheus exporter that throws stuff to httpd endpoints and evaluates their response." 6 | DEF_VERSION = "0.1.3" 7 | DEF_AUTHOR = "cytopia" 8 | DEF_GITHUB = "https://github.com/cytopia/prometheus-redbox_exporter" 9 | 10 | # Web server defaults 11 | DEF_SRV_LISTEN_ADDR = "0.0.0.0" 12 | DEF_SRV_LISTEN_PORT = 8080 13 | 14 | DEF_SCRAPE_TIMEOUT = 29 15 | 16 | # HTTP check defaults 17 | DEF_REQUEST_METHOD = "get" 18 | DEF_REQUEST_TIMEOUT = 60 19 | DEF_REQUEST_USERAGENT = "RedBox Exporter/" + DEF_VERSION 20 | 21 | 22 | # https://en.wikipedia.org/wiki/List_of_HTTP_status_codes 23 | STATUS_CODE_DESC = { 24 | "0": "Timeout", 25 | "100": "INFO: Continue", 26 | "101": "INFO: Switching Protocols", 27 | "102": "INFO: Processing", 28 | "103": "INFO: Early Hints", 29 | "200": "SUCCESS: OK", 30 | "201": "SUCCESS: Created", 31 | "202": "SUCCESS: Accepted", 32 | "203": "SUCCESS: Non-Authoritative Information", 33 | "204": "SUCCESS: No Content", 34 | "205": "SUCCESS: Reset Content", 35 | "206": "SUCCESS: Partial Content", 36 | "207": "SUCCESS: Multi-Status", 37 | "208": "SUCCESS: Already Reported", 38 | "226": "SUCCESS: IM Used", 39 | "300": "REDIRECT: Multiple Choices", 40 | "301": "REDIRECT: Moved Permanently", 41 | "302": "REDIRECT: Found", 42 | "303": "REDIRECT: See Other", 43 | "304": "REDIRECT: Not Modified", 44 | "305": "REDIRECT: Use Proxy", 45 | "306": "REDIRECT: Switch Proxy", 46 | "307": "REDIRECT: Temporary Redirect", 47 | "308": "REDIRECT: Permanent Redirect", 48 | "400": "CLIENT ERROR: Bad Request", 49 | "401": "CLIENT ERROR: Unauthorized", 50 | "402": "CLIENT ERROR: Payment Required", 51 | "403": "CLIENT ERROR: Forbidden", 52 | "404": "CLIENT ERROR: Not Found", 53 | "405": "CLIENT ERROR: Method Not Allowed", 54 | "406": "CLIENT ERROR: Not Acceptable", 55 | "407": "CLIENT ERROR: Proxy Authentication Required", 56 | "408": "CLIENT ERROR: Request Timeout", 57 | "409": "CLIENT ERROR: Conflict", 58 | "410": "CLIENT ERROR: Gone", 59 | "411": "CLIENT ERROR: Length Required", 60 | "412": "CLIENT ERROR: Precondition Failed", 61 | "413": "CLIENT ERROR: Payload Too Large", 62 | "414": "CLIENT ERROR: URI Too Long", 63 | "415": "CLIENT ERROR: Unsupported Media Type", 64 | "416": "CLIENT ERROR: Range Not Satisfiable", 65 | "417": "CLIENT ERROR: Expectation Failed", 66 | "418": "CLIENT ERROR: I'm a teapot", 67 | "421": "CLIENT ERROR: Misdirected Request", 68 | "422": "CLIENT ERROR: Unprocessable Entity", 69 | "423": "CLIENT ERROR: Locked", 70 | "424": "CLIENT ERROR: Failed Dependency", 71 | "425": "CLIENT ERROR: Too Early", 72 | "426": "CLIENT ERROR: Upgrade Required", 73 | "428": "CLIENT ERROR: Precondition Required", 74 | "429": "CLIENT ERROR: Too Many Requests", 75 | "431": "CLIENT ERROR: Request Header Fields Too Large", 76 | "451": "CLIENT ERROR: Unavailable For Legal Reasons", 77 | "500": "Internal Server Error", 78 | "501": "Not Implemented", 79 | "502": "Bad Gateway", 80 | "503": "Service Unavailable", 81 | "504": "Gateway Timeout", 82 | "505": "HTTP Version Not Supported", 83 | "506": "Variant Also Negotiates", 84 | "507": "Insufficient Storage", 85 | "508": "Loop Detected", 86 | "510": "Not Extended", 87 | "511": "Network Authentication Required", 88 | # "": "", 89 | # "": "", 90 | # "": "", 91 | # "": "", 92 | # "": "", 93 | } 94 | -------------------------------------------------------------------------------- /redbox/types/ds_response.py: -------------------------------------------------------------------------------- 1 | """Datatype definition.""" 2 | 3 | from typing import Dict, List, Any 4 | 5 | 6 | class DsResponse: 7 | """Datastructure for response.""" 8 | 9 | @property 10 | def name(self) -> str: 11 | """Unique name of this response.""" 12 | return self.__name 13 | 14 | @property 15 | def groups(self) -> Dict[str, str]: 16 | """Additional specified groups.""" 17 | return self.__groups 18 | 19 | @property 20 | def url(self) -> str: 21 | """Request URL.""" 22 | return self.__url 23 | 24 | @property 25 | def headers(self) -> Dict[str, str]: 26 | """Response headers.""" 27 | return self.__headers 28 | 29 | @property 30 | def body(self) -> bytes: 31 | """Response body.""" 32 | return self.__body 33 | 34 | @property 35 | def size(self) -> int: 36 | """Response body size in bytes.""" 37 | return self.__size 38 | 39 | @property 40 | def time_ttfb(self) -> float: 41 | """Time to first byte.""" 42 | return self.__time_ttfb 43 | 44 | @property 45 | def time_download(self) -> float: 46 | """Time taken to download response.""" 47 | return self.__time_download 48 | 49 | @property 50 | def time_render(self) -> float: 51 | """Time taken to render the document.""" 52 | return self.__time_render 53 | 54 | @property 55 | def time_total(self) -> float: 56 | """Total time taken.""" 57 | return self.__time_total 58 | 59 | @property 60 | def status_code(self) -> int: 61 | """HTTP status code returned from server.""" 62 | return self.__status_code 63 | 64 | @property 65 | def status_family(self) -> str: 66 | """HTTP status code family returned from server (e.g.: '4xx').""" 67 | return self.__status_family 68 | 69 | @property 70 | def success(self) -> bool: 71 | """Returns True if request was successful.""" 72 | return self.__success 73 | 74 | @success.setter 75 | def success(self, value: bool) -> None: 76 | self.__success = value 77 | 78 | @property 79 | def err_msg(self) -> str: 80 | """Contains the error message in case the request was not successful.""" 81 | return self.__err_msg 82 | 83 | @err_msg.setter 84 | def err_msg(self, value: str) -> None: 85 | self.__err_msg = value 86 | 87 | @property 88 | def extract(self) -> List[str]: 89 | """Contains a list of regex extracted string from the body.""" 90 | return self.__extract 91 | 92 | def __init__(self, response: Dict[str, Any]) -> None: 93 | self.__name = str(response["name"]) 94 | self.__groups = dict(response["groups"]) 95 | self.__url = str(response["url"]) 96 | self.__headers = dict(response["headers"]) 97 | self.__body = bytes(response["body"]) 98 | self.__size = int(response["size"]) 99 | self.__time_ttfb = float(response["time_ttfb"]) 100 | self.__time_download = float(response["time_download"]) 101 | self.__time_render = float(response["time_render"]) 102 | self.__time_total = float(response["time_total"]) 103 | self.__status_code = int(response["status_code"]) 104 | self.__status_family = str(response["status_family"]) 105 | self.__success = bool(response["success"]) 106 | self.__err_msg = str(response["err_msg"]) 107 | self.__extract = list(response["extract"]) 108 | -------------------------------------------------------------------------------- /redbox/request/classes/request.py: -------------------------------------------------------------------------------- 1 | """Abstract class definition.""" 2 | 3 | from typing import List, Dict 4 | 5 | import re 6 | from abc import ABC 7 | from abc import abstractmethod 8 | 9 | from ..types import DsTarget 10 | from ..types import DsResponse 11 | from ..conditions import evaluate_response 12 | 13 | 14 | class Request(ABC): 15 | """Abstract class to be implemented by all Request handlers.""" 16 | 17 | # -------------------------------------------------------------------------- 18 | # Abstract Functions 19 | # -------------------------------------------------------------------------- 20 | @abstractmethod 21 | def request(self, target: DsTarget) -> DsResponse: 22 | """Make a request and return the response.""" 23 | raise NotImplementedError 24 | 25 | # -------------------------------------------------------------------------- 26 | # Public Functions 27 | # -------------------------------------------------------------------------- 28 | @staticmethod 29 | def build_valid_response( 30 | target: DsTarget, 31 | headers: Dict[str, str], 32 | body: bytes, 33 | time_ttfb: float, 34 | time_download: float, 35 | time_render: float, 36 | status_code: int, 37 | ) -> DsResponse: 38 | """Get a filled in data structure for a succeeded response.""" 39 | return evaluate_response( 40 | target, 41 | DsResponse( 42 | { 43 | "name": target.name, 44 | "groups": target.groups, 45 | "url": target.url, 46 | "headers": headers, 47 | "body": body, 48 | "size": len(body), 49 | "time_ttfb": time_ttfb, 50 | "time_download": time_download, 51 | "time_render": time_render, 52 | "time_total": (time_ttfb + time_download + time_render), 53 | "status_code": status_code, 54 | "status_family": str(status_code)[0] + "xx", 55 | "success": True, 56 | "extract": Request.__extract_from_body(target, body, status_code), 57 | "err_msg": "", 58 | } 59 | ), 60 | ) 61 | 62 | @staticmethod 63 | def build_failed_response(target: DsTarget, err_msg: str) -> DsResponse: 64 | """Get a filled in data structure for a failed response.""" 65 | return DsResponse( 66 | { 67 | "name": target.name, 68 | "groups": target.groups, 69 | "url": target.url, 70 | "headers": [], 71 | "body": b"", 72 | "size": 0, 73 | "time_ttfb": 0, 74 | "time_download": 0, 75 | "time_render": 0, 76 | "time_total": 0, 77 | "status_code": 0, 78 | "status_family": "0xx", 79 | "success": False, 80 | "extract": [], 81 | "err_msg": err_msg, 82 | } 83 | ) 84 | 85 | # -------------------------------------------------------------------------- 86 | # Private Functions 87 | # -------------------------------------------------------------------------- 88 | @staticmethod 89 | def __extract_from_body(target: DsTarget, body: bytes, status_code: int) -> List[str]: 90 | """Create human readable error message.""" 91 | if target.extract: 92 | if str(status_code) in target.extract: 93 | for regex in target.extract[str(status_code)]: 94 | regobj = re.compile(regex.encode(), re.IGNORECASE) 95 | result = regobj.findall(body) 96 | if result: 97 | return [item.decode("utf-8") for item in result] 98 | 99 | if str(status_code)[0] + "xx" in target.extract: 100 | for regex in target.extract[str(status_code)[0] + "xx"]: 101 | regobj = re.compile(regex.encode(), re.IGNORECASE) 102 | result = regobj.findall(body) 103 | if result: 104 | return [item.decode("utf-8") for item in result] 105 | 106 | if "*" in target.extract: 107 | for regex in target.extract["*"]: 108 | regobj = re.compile(regex.encode(), re.IGNORECASE) 109 | result = regobj.findall(body) 110 | if result: 111 | return [item.decode("utf-8") for item in result] 112 | return [] 113 | -------------------------------------------------------------------------------- /etc/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # 3 | # Example configuration 4 | # 5 | 6 | 7 | # Both optional values can be overriden with command line arguments. 8 | # If neither of them are specified here or via command line arguments, 9 | # they default to 0.0.0.0:8080 10 | listen_port: 8080 11 | listen_addr: 0.0.0.0 12 | 13 | 14 | # Set this to one second less than Prometheus' scrape_timeout value. 15 | scrape_timeout: 29 16 | 17 | 18 | # This defines the targets you want to monitor 19 | # See redbox/config/template.py for all possible values and types. 20 | targets: 21 | 22 | 23 | #- name: # Must be unique across all target entries 24 | # # Will be available in Prometheus via name="" 25 | # 26 | # groups: # Key will be available in Prometheus as group_="" its valuea 27 | # # Useful for filtering 28 | # name: google # group_name="google" 29 | # group: searchengine # group_group="searchengine" 30 | # country: de # group_country="de" 31 | # env: prod # group_env="prod" 32 | # page: homepage # group_page="homepage" 33 | # 34 | # url: https://www.google.de # URL to scrape 35 | # method: get # get, post, put, delete, head, etc 36 | # params: {} # Key value pair of params (like curl -d ) 37 | # headers: {} # Key value pair of additional headers 38 | # timeout: 28 # Timeout (should be shorter than scrape_timeout) 39 | # fail_if: # Fail conditions 40 | # status_code_not_in: [200] # evaluates status code 41 | # extract: # List of regexes to extract data from the response body 42 | # '*': # Extract for all return status codes 43 | # - '.{500}connect to server.{500}' 44 | # - '\(.+)\<\/h1\>' 45 | # '5xx': # Extract if any 5xx return status code 46 | # - '.{500}connect to server.{500}' 47 | # '505': # Only extract if return status code equals 505 48 | # - '.{500}connect to server.{500}' 49 | 50 | 51 | 52 | 53 | # ----------------------------------------------------------------------------------------------- 54 | # google.de 55 | # ----------------------------------------------------------------------------------------------- 56 | 57 | # homepage 58 | - name: google.de 59 | groups: 60 | name: google 61 | group: searchengine 62 | country: de 63 | env: prod 64 | page: homepage 65 | url: https://www.google.de 66 | method: get 67 | params: {} 68 | headers: {} 69 | timeout: 28 70 | fail_if: 71 | status_code_not_in: [200] 72 | extract: 73 | '*': 74 | - '.{500}connect to server.{500}' 75 | - '\(.+)\<\/h1\>' 76 | 77 | # searchpage 78 | - name: google.de-search 79 | groups: 80 | name: google 81 | group: searchengine 82 | country: de 83 | env: prod 84 | page: search 85 | url: https://www.google.de/search?q=test 86 | method: get 87 | params: {} 88 | headers: {} 89 | timeout: 28 90 | fail_if: 91 | status_code_not_in: [200] 92 | extract: 93 | '*': 94 | - '.{500}connect to server.{500}' 95 | - '\(.+)\<\/h1\>' 96 | 97 | 98 | # ----------------------------------------------------------------------------------------------- 99 | # google.com 100 | # ----------------------------------------------------------------------------------------------- 101 | 102 | # homepage 103 | - name: google.com 104 | groups: 105 | name: google 106 | group: searchengine 107 | country: us 108 | env: prod 109 | page: homepage 110 | url: https://www.google.com 111 | method: get 112 | params: {} 113 | headers: {} 114 | timeout: 28 115 | fail_if: 116 | status_code_not_in: [200] 117 | extract: 118 | '*': 119 | - '.{500}connect to server.{500}' 120 | - '\(.+)\<\/h1\>' 121 | 122 | # searchpage 123 | - name: google.com-search 124 | groups: 125 | name: google 126 | group: searchengine 127 | country: us 128 | env: prod 129 | page: search 130 | url: https://www.google.com/search?q=test 131 | method: get 132 | params: {} 133 | headers: {} 134 | timeout: 28 135 | fail_if: 136 | status_code_not_in: [200] 137 | extract: 138 | '*': 139 | - '.{500}connect to server.{500}' 140 | - '\(.+)\<\/h1\>' 141 | 142 | 143 | # ----------------------------------------------------------------------------------------------- 144 | # duckduckgo.com 145 | # ----------------------------------------------------------------------------------------------- 146 | 147 | # homepage 148 | - name: duckduckgo.com 149 | groups: 150 | name: ddg 151 | group: searchengine 152 | country: us 153 | env: prod 154 | page: homepage 155 | url: https://duckduckgo.com/ 156 | method: get 157 | params: {} 158 | headers: {} 159 | timeout: 28 160 | fail_if: 161 | status_code_not_in: [200] 162 | extract: 163 | '*': 164 | - '.{500}connect to server.{500}' 165 | - '\(.+)\<\/h1\>' 166 | 167 | # searchpage 168 | - name: duckduckgo.com-search 169 | groups: 170 | name: ddg 171 | group: searchengine 172 | country: us 173 | env: prod 174 | page: search 175 | url: https://duckduckgo.com/?q=test 176 | method: get 177 | params: {} 178 | headers: {} 179 | timeout: 28 180 | fail_if: 181 | status_code_not_in: [200] 182 | extract: 183 | '*': 184 | - '.{500}connect to server.{500}' 185 | - '\(.+)\<\/h1\>' 186 | -------------------------------------------------------------------------------- /example/volumes/redbox/conf.yml-example: -------------------------------------------------------------------------------- 1 | --- 2 | # 3 | # Example configuration 4 | # 5 | 6 | 7 | # Both optional values can be overriden with command line arguments. 8 | # If neither of them are specified here or via command line arguments, 9 | # they default to 0.0.0.0:8080 10 | listen_port: 8080 11 | listen_addr: 0.0.0.0 12 | 13 | 14 | # Set this to one second less than Prometheus' scrape_timeout value. 15 | scrape_timeout: 29 16 | 17 | 18 | # This defines the targets you want to monitor 19 | # See redbox/config/template.py for all possible values and types. 20 | targets: 21 | 22 | 23 | #- name: # Must be unique across all target entries 24 | # # Will be available in Prometheus via name="" 25 | # 26 | # groups: # Key will be available in Prometheus as group_="" its valuea 27 | # # Useful for filtering 28 | # name: google # group_name="google" 29 | # group: searchengine # group_group="searchengine" 30 | # country: de # group_country="de" 31 | # env: prod # group_env="prod" 32 | # page: homepage # group_page="homepage" 33 | # 34 | # url: https://www.google.de # URL to scrape 35 | # method: get # get, post, put, delete, head, etc 36 | # params: {} # Key value pair of params (like curl -d ) 37 | # headers: {} # Key value pair of additional headers 38 | # timeout: 28 # Timeout (should be shorter than scrape_timeout) 39 | # fail_if: # Fail conditions 40 | # status_code_not_in: [200] # evaluates status code 41 | # extract: # List of regexes to extract data from the response body 42 | # '*': # Extract for all return status codes 43 | # - '.{500}connect to server.{500}' 44 | # - '\(.+)\<\/h1\>' 45 | # '5xx': # Extract if any 5xx return status code 46 | # - '.{500}connect to server.{500}' 47 | # '505': # Only extract if return status code equals 505 48 | # - '.{500}connect to server.{500}' 49 | 50 | 51 | 52 | 53 | # ----------------------------------------------------------------------------------------------- 54 | # google.de 55 | # ----------------------------------------------------------------------------------------------- 56 | 57 | # homepage 58 | - name: google.de 59 | groups: 60 | name: google 61 | group: searchengine 62 | country: de 63 | env: prod 64 | page: homepage 65 | url: https://www.google.de 66 | method: get 67 | params: {} 68 | headers: {} 69 | timeout: 28 70 | fail_if: 71 | status_code_not_in: [200] 72 | extract: 73 | '*': 74 | - '.{500}connect to server.{500}' 75 | - '\(.+)\<\/h1\>' 76 | 77 | # searchpage 78 | - name: google.de-search 79 | groups: 80 | name: google 81 | group: searchengine 82 | country: de 83 | env: prod 84 | page: search 85 | url: https://www.google.de/search?q=test 86 | method: get 87 | params: {} 88 | headers: {} 89 | timeout: 28 90 | fail_if: 91 | status_code_not_in: [200] 92 | extract: 93 | '*': 94 | - '.{500}connect to server.{500}' 95 | - '\(.+)\<\/h1\>' 96 | 97 | 98 | # ----------------------------------------------------------------------------------------------- 99 | # google.com 100 | # ----------------------------------------------------------------------------------------------- 101 | 102 | # homepage 103 | - name: google.com 104 | groups: 105 | name: google 106 | group: searchengine 107 | country: us 108 | env: prod 109 | page: homepage 110 | url: https://www.google.com 111 | method: get 112 | params: {} 113 | headers: {} 114 | timeout: 28 115 | fail_if: 116 | status_code_not_in: [200] 117 | extract: 118 | '*': 119 | - '.{500}connect to server.{500}' 120 | - '\(.+)\<\/h1\>' 121 | 122 | # searchpage 123 | - name: google.com-search 124 | groups: 125 | name: google 126 | group: searchengine 127 | country: us 128 | env: prod 129 | page: search 130 | url: https://www.google.com/search?q=test 131 | method: get 132 | params: {} 133 | headers: {} 134 | timeout: 28 135 | fail_if: 136 | status_code_not_in: [200] 137 | extract: 138 | '*': 139 | - '.{500}connect to server.{500}' 140 | - '\(.+)\<\/h1\>' 141 | 142 | 143 | # ----------------------------------------------------------------------------------------------- 144 | # duckduckgo.com 145 | # ----------------------------------------------------------------------------------------------- 146 | 147 | # homepage 148 | - name: duckduckgo.com 149 | groups: 150 | name: ddg 151 | group: searchengine 152 | country: us 153 | env: prod 154 | page: homepage 155 | url: https://duckduckgo.com/ 156 | method: get 157 | params: {} 158 | headers: {} 159 | timeout: 28 160 | fail_if: 161 | status_code_not_in: [200] 162 | extract: 163 | '*': 164 | - '.{500}connect to server.{500}' 165 | - '\(.+)\<\/h1\>' 166 | 167 | # searchpage 168 | - name: duckduckgo.com-search 169 | groups: 170 | name: ddg 171 | group: searchengine 172 | country: us 173 | env: prod 174 | page: search 175 | url: https://duckduckgo.com/?q=test 176 | method: get 177 | params: {} 178 | headers: {} 179 | timeout: 28 180 | fail_if: 181 | status_code_not_in: [200] 182 | extract: 183 | '*': 184 | - '.{500}connect to server.{500}' 185 | - '\(.+)\<\/h1\>' 186 | -------------------------------------------------------------------------------- /redbox/request/request_simple.py: -------------------------------------------------------------------------------- 1 | """Make HTTP requests to defined targets.""" 2 | 3 | from typing import Optional, Any, Tuple, Union 4 | 5 | import re 6 | import sys 7 | import timeit 8 | 9 | import requests 10 | from requests.auth import HTTPBasicAuth 11 | from requests.auth import HTTPDigestAuth 12 | 13 | from .types import DsResponse 14 | from .types import DsTarget 15 | from .classes import Request 16 | 17 | 18 | class RequestSimple(Request): 19 | """Simple HTTP request.""" 20 | 21 | # -------------------------------------------------------------------------- 22 | # Public Functions 23 | # -------------------------------------------------------------------------- 24 | def request(self, target: DsTarget) -> DsResponse: 25 | """Make Http request and return response.""" 26 | error = "" 27 | failed = 0 28 | request_time = float(0) 29 | try: 30 | auth = RequestSimple.__get_auth(target) 31 | timeout = RequestSimple.__get_timeout(target) 32 | 33 | start = timeit.default_timer() 34 | response = requests.request( 35 | target.method, 36 | target.url, 37 | params=target.params, 38 | headers=target.headers, 39 | timeout=timeout, 40 | auth=auth, 41 | ) 42 | # Note: response.elapsed.total_seconds() will only get you the time it takes 43 | # until you get the return headers without the response contents. 44 | # So here we also measure the complete request time including the body response. 45 | request_time = timeit.default_timer() - start 46 | 47 | except requests.exceptions.URLRequired as url_err: 48 | error = str(url_err) 49 | failed = 1 50 | except requests.exceptions.HTTPError as http_err: 51 | error = str(http_err) 52 | failed = 1 53 | except requests.exceptions.TooManyRedirects as redir_err: 54 | error = str(redir_err) 55 | failed = 1 56 | except requests.exceptions.ConnectTimeout as conn_time_err: 57 | error = str(conn_time_err) 58 | failed = 1 59 | except requests.exceptions.ReadTimeout as read_time_err: 60 | error = str(read_time_err) 61 | failed = 1 62 | except requests.exceptions.Timeout as time_err: 63 | error = str(time_err) 64 | failed = 1 65 | except requests.exceptions.ConnectionError as conn_err: 66 | error = str(conn_err) 67 | failed = 1 68 | except requests.exceptions.RequestException as req_err: 69 | error = str(req_err) 70 | failed = 1 71 | else: 72 | response.close() 73 | 74 | print( 75 | "Target Response [{}]: {} sec for {}".format( 76 | 0 if failed else response.status_code, "{0:.3f}".format(request_time), target.name 77 | ), 78 | file=sys.stderr, 79 | ) 80 | 81 | if failed == 1: 82 | return self.build_failed_response(target, RequestSimple.__format_error(error)) 83 | 84 | return self.build_valid_response( 85 | target, 86 | dict(response.headers), 87 | response.content, 88 | response.elapsed.total_seconds(), 89 | request_time - response.elapsed.total_seconds(), 90 | float(0), 91 | response.status_code, 92 | ) 93 | 94 | # -------------------------------------------------------------------------- 95 | # Private Functions 96 | # -------------------------------------------------------------------------- 97 | @staticmethod 98 | def __format_error(error: str) -> str: 99 | """Create human readable error message.""" 100 | regex = re.compile("(.*object at 0x[A-Fa-f0-9]+>[,:])(.*)") 101 | match = regex.match(error) 102 | if match: 103 | try: 104 | human = match.group(len(match.groups())) 105 | human = human.strip() 106 | human = human.rstrip("'))") 107 | return human 108 | except AttributeError: 109 | pass 110 | return str(error) 111 | 112 | @staticmethod 113 | def __get_auth(target: DsTarget) -> Optional[Any]: 114 | """Get authentication mechanism if defined.""" 115 | if target.basic_auth: 116 | return HTTPBasicAuth( 117 | target.basic_auth["username"], 118 | target.basic_auth["password"], 119 | ) 120 | if target.digest_auth: 121 | return HTTPDigestAuth( 122 | target.digest_auth["username"], 123 | target.digest_auth["password"], 124 | ) 125 | return None 126 | 127 | @staticmethod 128 | def __get_timeout(target: DsTarget) -> Tuple[Union[int, float], Union[int, float]]: 129 | """Get timeout values.""" 130 | # The connect timeout is the number of seconds Requests will wait for your client to 131 | # establish a connection to a remote machine (corresponding to the connect()) call on 132 | # the socket. It’s a good practice to set connect timeouts to slightly larger than a 133 | # multiple of 3, which is the default TCP packet retransmission window. 134 | conn_timeout = target.timeout 135 | # Once your client has connected to the server and sent the HTTP request, the read timeout 136 | # is the number of seconds the client will wait for the server to send a response. 137 | # (Specifically, it’s the number of seconds that the client will wait between bytes sent 138 | # from the server. 139 | read_timeout = target.timeout 140 | return (conn_timeout, read_timeout) 141 | -------------------------------------------------------------------------------- /redbox/config/config.py: -------------------------------------------------------------------------------- 1 | """Read yaml configuration file and return a parsed dictionary.""" 2 | 3 | from typing import Dict, List, Any 4 | 5 | import argparse 6 | import errno 7 | import os 8 | import re 9 | import yaml 10 | 11 | from .types import DsConfig 12 | from .types import DsTarget 13 | from .template import CONFIG_TEMPLATE 14 | 15 | 16 | def _read_config_file(path: str) -> Dict[Any, Any]: 17 | """Load configuration file and return yaml dictionary. 18 | 19 | Args: 20 | path (str): Path to configuration file. 21 | 22 | Returns: 23 | dict: Configuration in yaml format (Python dict). 24 | 25 | Raises: 26 | OSError: If file not found or yaml cannot be parsed. 27 | """ 28 | try: 29 | file_p = open(path) 30 | except FileNotFoundError as err_file: 31 | error = os.strerror(errno.ENOENT) 32 | raise OSError(f"[ERROR] {error}: {path}") from err_file 33 | except PermissionError as err_perm: 34 | error = os.strerror(errno.EACCES) 35 | raise OSError(f"[ERROR] {error}: {path}") from err_perm 36 | else: 37 | try: 38 | return dict(yaml.safe_load(file_p)) 39 | except yaml.YAMLError as err_yaml: 40 | error = str(err_yaml) 41 | raise OSError(f"[ERROR]: {path}\n{error}") from err_yaml 42 | finally: 43 | file_p.close() 44 | 45 | 46 | def _check_duplicate_targets(targets: List[Any]) -> None: 47 | """Check if "- name" key has duplicate values. 48 | 49 | Args: 50 | config (dict): Yaml configuration. 51 | 52 | Raises: 53 | OSError: If configuration file is not valid. 54 | """ 55 | names = [] 56 | for index, target in enumerate(targets): 57 | name = target["name"] 58 | if name in names: 59 | raise OSError(f"[CONFIG-FAIL] conf[target][{index}[name] has duplicate value '{name}'") 60 | names.append(name) 61 | 62 | 63 | def _check_config(section: str, config: Dict[Any, Any], template: Dict[Any, Any]) -> None: 64 | """Recursively check configuration. 65 | 66 | Args: 67 | section (str): Name of the current section to validate. 68 | config (dict): Yaml configuration. 69 | template (dict): Configuration template. 70 | 71 | Raises: 72 | OSError: If configuration file is not valid. 73 | """ 74 | for key in template: 75 | # print(f"[CONFIG-INFO] checking {section}[{key}]") 76 | 77 | # Check Required 78 | if template[key]["required"] and key not in config: 79 | raise OSError(f"[CONFIG-FAIL] {section}[{key}] not defined, but required") 80 | 81 | # Check Type 82 | if key in config and not isinstance(config[key], template[key]["type"]): 83 | req_type = template[key]["type"] 84 | raise OSError(f"[CONFIG-FAIL] {section}[{key}] must be of type: {req_type}") 85 | 86 | # Check Allowed value by Regex 87 | if key in config and "allowed" in template[key]: 88 | regex = template[key]["allowed"] 89 | value = str(config[key]) 90 | regobj = re.compile(regex) 91 | if regobj.match(value) is None: 92 | raise OSError(f"[CONFIG-FAIL] {section}[{key}] = '{value}' must match: '{regex}'") 93 | 94 | # Recurse into childs 95 | if template[key]["childs"]: 96 | if key in config and isinstance(config[key], dict): 97 | _check_config(section + f"[{key}]", config[key], template[key]["childs"]) 98 | if key in config and isinstance(config[key], list): 99 | for index, value in enumerate(config[key]): 100 | _check_config( 101 | section + f"[{key}][{index}]", config[key][index], template[key]["childs"] 102 | ) 103 | 104 | 105 | def _merge_defaults( 106 | section: str, config: Dict[Any, Any], template: Dict[Any, Any] 107 | ) -> Dict[Any, Any]: 108 | """Recursively merge configuration with defined default values from template. 109 | 110 | Args: 111 | section (str): Name of the current section to validate. 112 | config (dict): Yaml configuration. 113 | defaults (dict): Default values. 114 | 115 | Returns: 116 | dict: Final configuration file. 117 | """ 118 | for key in template: 119 | # print(f"[CONFIG-INFO] checking {section}[{key}]") 120 | 121 | # Recurse into childs 122 | if template[key]["childs"] and "default" not in template[key]: 123 | if key in config: 124 | if template[key]["type"] == list: 125 | for index, value in enumerate(config[key]): 126 | config[key][index] = _merge_defaults( 127 | section + f"[{key}][{index}]", 128 | value, 129 | template[key]["childs"], 130 | ) 131 | else: 132 | config[key] = _merge_defaults( 133 | section + f"[{key}]", config[key], template[key]["childs"] 134 | ) 135 | # Flat default values 136 | else: 137 | if key not in config and "default" in template[key]: 138 | config[key] = template[key]["default"] 139 | 140 | return config 141 | 142 | 143 | def get_config(args: argparse.Namespace) -> DsConfig: 144 | """Return configuration file as dictionary. 145 | 146 | Args: 147 | args (dict): Parsed command line arguments 148 | 149 | Returns: 150 | dict: Configuration 151 | 152 | Raises: 153 | OSError: If file not found, invalid yaml or invalid config. 154 | """ 155 | conf = _read_config_file(args.conf) 156 | 157 | # Validate 158 | _check_config("conf", conf, CONFIG_TEMPLATE) 159 | _check_duplicate_targets(conf["targets"]) 160 | 161 | # Merge with defaults 162 | conf = _merge_defaults("conf", conf, CONFIG_TEMPLATE) 163 | 164 | # Override with command line arguments if exist 165 | if args.listen is not None: 166 | conf["listen_addr"] = args.listen 167 | if args.port is not None: 168 | conf["listen_port"] = args.port 169 | 170 | # Return with correct data type 171 | return DsConfig( 172 | conf["scrape_timeout"], 173 | conf["listen_addr"], 174 | conf["listen_port"], 175 | [DsTarget(target) for target in conf["targets"]], 176 | ) 177 | -------------------------------------------------------------------------------- /redbox/prometheus.py: -------------------------------------------------------------------------------- 1 | """Converts response list into prometheus format.""" 2 | 3 | from typing import List, Dict, Any 4 | 5 | from .types import DsResponse 6 | 7 | 8 | METRIC_PREFIX = "redbox" 9 | 10 | 11 | def __float2str(value: float) -> str: 12 | """Convert a float into a human readable string representatoin.""" 13 | if value == 0.0: 14 | return "0.0" 15 | return "{0:.5f}".format(value) 16 | 17 | 18 | def _get_metrics(responses: List[DsResponse], metric_settings: Dict[str, Any]) -> List[str]: 19 | """Wrapper function to get formated prometheus metrics.""" 20 | m_name = metric_settings["name"] 21 | m_type = metric_settings["type"] 22 | m_help = metric_settings["help"] 23 | m_func = metric_settings["func"] 24 | 25 | metric = METRIC_PREFIX + "_" + m_name 26 | lines = [ 27 | f"# HELP {metric} {m_help}", 28 | f"# TYPE {metric} {m_type}", 29 | ] 30 | 31 | for response in responses: 32 | name = response.name 33 | groups = ['group_{}="{}",'.format(key, response.groups[key]) for key in response.groups] 34 | url = response.url 35 | size = response.size 36 | time_ttfb = __float2str(response.time_ttfb) 37 | time_download = __float2str(response.time_download) 38 | time_render = __float2str(response.time_render) 39 | time_total = __float2str(response.time_total) 40 | status_code = response.status_code 41 | status_family = response.status_family 42 | success = "1" if response.success else "0" 43 | err_msg = response.err_msg 44 | extract = "\\n".join(response.extract) 45 | 46 | lines.append( 47 | f"{metric}{{" 48 | f'name="{name}",' + "".join(groups) + f'url="{url}",' 49 | f'bytes="{size}",' 50 | f'time_ttfb="{time_ttfb}",' 51 | f'time_download="{time_download}",' 52 | f'time_render="{time_render}",' 53 | f'time_total="{time_total}",' 54 | f'status_code="{status_code}",' 55 | f'status_family="{status_family}",' 56 | f'success="{success}",' 57 | f'err_msg="{err_msg}",' 58 | f'extract="{extract}"' 59 | "} " + m_func(response) 60 | ) 61 | return lines 62 | 63 | 64 | def _get_time_ttfb(responses: List[DsResponse]) -> List[str]: 65 | """Get formated TTFB time metrics.""" 66 | metric_settings = { 67 | "name": "time_ttfb", 68 | "type": "untyped", 69 | "help": "Returns the TTFB time in seconds (time taken for headers to arrive).", 70 | "func": lambda response: __float2str(response.time_ttfb), 71 | } 72 | return _get_metrics(responses, metric_settings) 73 | 74 | 75 | def _get_time_download(responses: List[DsResponse]) -> List[str]: 76 | """Get formated download time metrics.""" 77 | metric_settings = { 78 | "name": "time_download", 79 | "type": "untyped", 80 | "help": "Returns the download time in seconds (time taken to download the body).", 81 | "func": lambda response: __float2str(response.time_download), 82 | } 83 | return _get_metrics(responses, metric_settings) 84 | 85 | 86 | def _get_time_render(responses: List[DsResponse]) -> List[str]: 87 | """Get formated render time metrics.""" 88 | metric_settings = { 89 | "name": "time_render", 90 | "type": "untyped", 91 | "help": "Returns the render time in seconds (time taken to HTML/JS render the body).", 92 | "func": lambda response: __float2str(response.time_render), 93 | } 94 | return _get_metrics(responses, metric_settings) 95 | 96 | 97 | def _get_time_total(responses: List[DsResponse]) -> List[str]: 98 | """Get formated total time metrics.""" 99 | metric_settings = { 100 | "name": "time_total", 101 | "type": "untyped", 102 | "help": "Returns the total time in seconds (time taken to request, render and download).", 103 | "func": lambda response: __float2str(response.time_total), 104 | } 105 | return _get_metrics(responses, metric_settings) 106 | 107 | 108 | def _get_content_size(responses: List[DsResponse]) -> List[str]: 109 | """Get formated content size metrics.""" 110 | metric_settings = { 111 | "name": "content_size", 112 | "type": "untyped", 113 | "help": "Returns the content size in bytes.", 114 | "func": lambda response: str(response.size), 115 | } 116 | return _get_metrics(responses, metric_settings) 117 | 118 | 119 | def _get_failure(responses: List[DsResponse]) -> List[str]: 120 | """Get formated failure metrics.""" 121 | metric_settings = { 122 | "name": "failure", 123 | "type": "untyped", 124 | "help": "Returns '1' if request or defined conditions fail or '0' on success.", 125 | "func": lambda response: "0" if response.success else "1", 126 | } 127 | return _get_metrics(responses, metric_settings) 128 | 129 | 130 | def _get_success(responses: List[DsResponse]) -> List[str]: 131 | """Get formated success metrics.""" 132 | metric_settings = { 133 | "name": "success", 134 | "type": "untyped", 135 | "help": "Returns '1' if request and defined conditions succeed or '0' on failure.", 136 | "func": lambda response: "1" if response.success else "0", 137 | } 138 | return _get_metrics(responses, metric_settings) 139 | 140 | 141 | def _get_status_code(responses: List[DsResponse]) -> List[str]: 142 | """Get formated status code metrics.""" 143 | metric_settings = { 144 | "name": "status_code", 145 | "type": "untyped", 146 | "help": "Returns the response http status code or '0' if request failed.", 147 | "func": lambda response: str(response.status_code), 148 | } 149 | return _get_metrics(responses, metric_settings) 150 | 151 | 152 | def get_prom_format(responses: List[DsResponse]) -> str: 153 | """Format response list into prometheus format.""" 154 | times_ttfb = _get_time_ttfb(responses) 155 | times_download = _get_time_download(responses) 156 | times_render = _get_time_render(responses) 157 | times_total = _get_time_total(responses) 158 | sizes = _get_content_size(responses) 159 | fails = _get_failure(responses) 160 | success = _get_success(responses) 161 | status_codes = _get_status_code(responses) 162 | return "\n".join( 163 | times_ttfb 164 | + times_download 165 | + times_render 166 | + times_total 167 | + sizes 168 | + fails 169 | + success 170 | + status_codes 171 | ) 172 | -------------------------------------------------------------------------------- /redbox/config/template.py: -------------------------------------------------------------------------------- 1 | """This file defines the configuration file template. 2 | 3 | type: Determines the valid type for this key. 4 | default: Gives a default value if none was defined. 5 | valid: List of valid values for this key. 6 | regexp: Regexp expression for valid values of this key. 7 | required: Determines if this key is requuired or not. 8 | childs: Defines child nodes if key is a list or dictionary. 9 | """ 10 | from ..defaults import DEF_SCRAPE_TIMEOUT 11 | from ..defaults import DEF_SRV_LISTEN_ADDR, DEF_SRV_LISTEN_PORT 12 | from ..defaults import DEF_REQUEST_METHOD, DEF_REQUEST_TIMEOUT 13 | 14 | 15 | CONFIG_TEMPLATE = { 16 | "scrape_timeout": { 17 | "type": int, 18 | "default": DEF_SCRAPE_TIMEOUT, 19 | "required": False, 20 | "allowed": "^[0-9]+$", 21 | "childs": {}, 22 | }, 23 | "listen_addr": { 24 | "type": str, 25 | "default": DEF_SRV_LISTEN_ADDR, 26 | "required": False, 27 | "childs": {}, 28 | }, 29 | "listen_port": { 30 | "type": int, 31 | "default": DEF_SRV_LISTEN_PORT, 32 | "required": False, 33 | "allowed": "^[0-9]+$", 34 | "childs": {}, 35 | }, 36 | "targets": { 37 | "type": list, 38 | "required": True, 39 | "childs": { 40 | "name": { 41 | "type": str, 42 | "required": True, 43 | "childs": {}, 44 | }, 45 | "groups": { 46 | "type": dict, 47 | "required": False, 48 | "default": {}, 49 | "childs": {}, 50 | }, 51 | "url": { 52 | "type": str, 53 | "required": True, 54 | "childs": {}, 55 | }, 56 | "method": { 57 | "type": str, 58 | "required": False, 59 | "default": DEF_REQUEST_METHOD, 60 | "allowed": "^(get|options|head|post|put|patch|delete)$", 61 | "childs": {}, 62 | }, 63 | "params": { 64 | "type": dict, 65 | "required": False, 66 | "default": {}, 67 | "childs": {}, 68 | }, 69 | "headers": { 70 | "type": dict, 71 | "required": False, 72 | "default": {}, 73 | "childs": {}, 74 | }, 75 | "timeout": { 76 | "type": (int, float), 77 | "required": False, 78 | "default": DEF_REQUEST_TIMEOUT, 79 | "childs": {}, 80 | }, 81 | "redirect": { 82 | "type": bool, 83 | "required": False, 84 | "default": False, 85 | "childs": {}, 86 | }, 87 | "basic_auth": { 88 | "type": dict, 89 | "required": False, 90 | "default": {}, 91 | "childs": { 92 | "username": { 93 | "type": str, 94 | "required": False, 95 | "childs": {}, 96 | }, 97 | "password": { 98 | "type": str, 99 | "required": False, 100 | "childs": {}, 101 | }, 102 | }, 103 | }, 104 | "digest_auth": { 105 | "type": dict, 106 | "required": False, 107 | "default": {}, 108 | "childs": { 109 | "username": { 110 | "type": str, 111 | "required": False, 112 | "childs": {}, 113 | }, 114 | "password": { 115 | "type": str, 116 | "required": False, 117 | "childs": {}, 118 | }, 119 | }, 120 | }, 121 | "fail_if": { 122 | "type": dict, 123 | "required": False, 124 | "default": {}, 125 | "childs": { 126 | "header_matches_regexp": { 127 | "type": list, 128 | "required": False, 129 | "default": [], 130 | "childs": { 131 | "header": { 132 | "type": str, 133 | "required": True, 134 | "childs": {}, 135 | }, 136 | "allow_missing": { 137 | "type": bool, 138 | "required": True, 139 | "childs": {}, 140 | }, 141 | "regexp": { 142 | "type": str, 143 | "required": True, 144 | "childs": {}, 145 | }, 146 | }, 147 | }, 148 | "header_not_matches_regexp": { 149 | "type": list, 150 | "required": False, 151 | "default": [], 152 | "childs": { 153 | "header": { 154 | "type": str, 155 | "required": True, 156 | "childs": {}, 157 | }, 158 | "allow_missing": { 159 | "type": bool, 160 | "required": True, 161 | "childs": {}, 162 | }, 163 | "regexp": { 164 | "type": str, 165 | "required": True, 166 | "childs": {}, 167 | }, 168 | }, 169 | }, 170 | }, 171 | }, 172 | "extract": { 173 | "type": dict, 174 | "required": False, 175 | "default": {}, 176 | "childs": {}, 177 | }, 178 | }, 179 | }, 180 | } 181 | 182 | # targets: 183 | # fail_if: 184 | # regexp_header_matches: 185 | # - header: Set-Cookie 186 | # allow_missing: True 187 | # regexp: '.*' 188 | # regexp_header_not_matches: [] 189 | # regexp_body_matches: [] 190 | # regexp_body_not_matches: [] 191 | # response_time_lt: 30 192 | # response_time_gt: 60 193 | # response_size_lt: 50 194 | # response_size_gt: 50 195 | # status_code_in: [] 196 | # status_code_not_int: [] 197 | -------------------------------------------------------------------------------- /redbox/__init__.py: -------------------------------------------------------------------------------- 1 | """Main file for redbox_exporter.""" 2 | 3 | from typing import Callable, Any 4 | 5 | import concurrent.futures 6 | import os 7 | import sys 8 | import threading 9 | import time 10 | import timeit 11 | 12 | from http.server import HTTPServer, BaseHTTPRequestHandler 13 | from socketserver import ThreadingMixIn 14 | 15 | from .args import * 16 | from .config import * 17 | from .request import * 18 | from .prometheus import * 19 | 20 | 21 | class Handler(BaseHTTPRequestHandler): 22 | """Simple webserver to serve metrics.""" 23 | 24 | def __init__(self, cfg: DsConfig, req: Request, *args: Any, **kwargs: Any) -> None: 25 | self.cfg = cfg 26 | self.req = req 27 | BaseHTTPRequestHandler.__init__(self, *args, **kwargs) 28 | 29 | homepage = """ 30 | 31 | 32 | 33 |

{DEF_NAME}

34 |

{DEF_DESC}

35 | 41 |

Go to /metrics to access metrics.

42 | 43 | """.format( 44 | DEF_NAME=DEF_NAME, 45 | DEF_DESC=DEF_DESC, 46 | DEF_VERSION=DEF_VERSION, 47 | DEF_AUTHOR=DEF_AUTHOR, 48 | DEF_GITHUB=DEF_GITHUB, 49 | ) 50 | 51 | def do_GET(self) -> None: # # pylint: disable=invalid-name 52 | """Serves GET requests.""" 53 | time_calling_start = timeit.default_timer() 54 | 55 | # Display homepage 56 | if self.path == "/": 57 | self.send_response(200) 58 | self.send_header("Content-type", "text/html") 59 | self.end_headers() 60 | self.wfile.write(self.homepage.encode() + b"\n") 61 | return 62 | # We don't have a favicon 63 | if self.path == "/favicon.ico": 64 | self.send_response(404) 65 | self.end_headers() 66 | return 67 | # Redirect on wrong paths 68 | if self.path != "/metrics": 69 | self.send_response(302) 70 | self.send_header("Location", "/metrics") 71 | self.end_headers() 72 | return 73 | 74 | # Check targets for their http response 75 | # Run threaded so the time taken is not summed up by each defined target. 76 | responses = [] 77 | config = self.cfg 78 | request = self.req 79 | 80 | targets = config.targets 81 | timeout = config.scrape_timeout 82 | executor = concurrent.futures.ThreadPoolExecutor() 83 | future_tasks = {executor.submit(request.request, target): target for target in targets} 84 | time_threads_start = timeit.default_timer() 85 | has_timeout = False 86 | try: 87 | for future in concurrent.futures.as_completed(future_tasks, timeout=timeout): 88 | responses.append(future.result()) 89 | except concurrent.futures.TimeoutError: 90 | has_timeout = True 91 | time_threads_end = timeit.default_timer() 92 | print( 93 | "Threads timed out after: {0:.6f} sec".format( 94 | time_threads_end - time_threads_start 95 | ), 96 | file=sys.stderr, 97 | ) 98 | else: 99 | time_threads_end = timeit.default_timer() 100 | 101 | # If we encountered a thread timeout, we fill up all targets which had a timeout 102 | # with default values and a timeout error; 103 | if has_timeout: 104 | for desired in targets: 105 | desired_name = desired.name 106 | need_to_add = True 107 | for actual in responses: 108 | actual_name = actual.name 109 | if desired_name == actual_name: 110 | need_to_add = False 111 | break 112 | if need_to_add: 113 | responses.append( 114 | request.build_failed_response( 115 | desired, 116 | "Timeout: Scrape timeout after {0:.6f}s".format( 117 | time_threads_end - time_threads_start 118 | ), 119 | ) 120 | ) 121 | 122 | # Convert to prometheus format 123 | time_metrics_start = time_threads_end 124 | metrics = get_prom_format(responses) 125 | time_metrics_end = timeit.default_timer() 126 | 127 | # Send response to scraper 128 | time_serving_start = time_metrics_end 129 | self.send_response(200) 130 | self.send_header("Content-type", "text/plain") 131 | self.end_headers() 132 | self.wfile.write(metrics.encode() + b"\n") 133 | time_serving_end = timeit.default_timer() 134 | 135 | # Add timing information for logging 136 | print("Threads: {0:.5f}s".format(time_threads_end - time_threads_start), file=sys.stderr) 137 | print("Convert: {0:.5f}s".format(time_metrics_end - time_metrics_start), file=sys.stderr) 138 | print("Respond: {0:.5f}s".format(time_serving_end - time_serving_start), file=sys.stderr) 139 | print("Overall: {0:.5f}s".format(time_serving_end - time_calling_start), file=sys.stderr) 140 | 141 | 142 | class ThreadingSimpleServer(ThreadingMixIn, HTTPServer): 143 | """Implement a threaded HTTP server. 144 | 145 | https://docs.python.org/3/library/socketserver.html#socketserver.ThreadingMixIn 146 | """ 147 | 148 | # def __init__(self, digest_auth_handler, *args, **kwargs): 149 | # # This has to be set before calling our parent's __init__(), which will 150 | # # try to call do_GET(). 151 | # self.digest_auth_handler = digest_auth_handler 152 | # BaseHTTPRequestHandler.__init__(self, *args, **kwargs) 153 | 154 | 155 | def run_webserver(conf: DsConfig) -> None: 156 | """Run webserver to serve metrics.""" 157 | print(time.asctime(), "Starting webserver on {}:{}".format(conf.listen_addr, conf.listen_port)) 158 | # Initialize and run web server 159 | 160 | def handler_with_extra_args(cfg: DsConfig, req: Request) -> Callable[[Any], Handler]: 161 | return lambda *args: Handler(cfg, req, *args) 162 | 163 | # Make conf variable available in Handler 164 | req = RequestSimple() 165 | 166 | try: 167 | server = ThreadingSimpleServer( 168 | (conf.listen_addr, conf.listen_port), handler_with_extra_args(conf, req) 169 | ) 170 | except OSError as error: 171 | print(error, file=sys.stderr) 172 | sys.exit(1) 173 | # Serve 174 | try: 175 | server.serve_forever() 176 | except KeyboardInterrupt: 177 | pass 178 | # Shutdown 179 | server.server_close() 180 | print(time.asctime(), "Server Stopped") 181 | 182 | 183 | def main() -> None: 184 | """Run main entrypoint.""" 185 | cmd_args = get_args() 186 | 187 | # Get configuration 188 | try: 189 | conf = get_config(cmd_args) 190 | except OSError as error: 191 | print(error, file=sys.stderr) 192 | sys.exit(1) 193 | 194 | # Initialize and run web server 195 | run_webserver(conf) 196 | 197 | 198 | if __name__ == "__main__": 199 | main() 200 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifneq (,) 2 | .error This Makefile requires GNU Make. 3 | endif 4 | 5 | 6 | # ------------------------------------------------------------------------------------------------- 7 | # Can be changed 8 | # ------------------------------------------------------------------------------------------------- 9 | # This can be adjusted 10 | PYTHON_VERSION = 3.7 11 | 12 | 13 | # ------------------------------------------------------------------------------------------------- 14 | # Default configuration 15 | # ------------------------------------------------------------------------------------------------- 16 | .PHONY: help lint code build install clean 17 | SHELL := /bin/bash 18 | 19 | NAME = $(shell grep -E '^[[:space:]]*name' setup.py | awk -F'"' '{print $$2}' | sed 's/-/_/g' ) 20 | VENV = env 21 | SRC = $(NAME) 22 | 23 | FL_VERSION = 0.4 24 | FL_IGNORES = .git/,.github/,$(NAME).egg-info,.mypy_cache/ 25 | 26 | 27 | # ------------------------------------------------------------------------------------------------- 28 | # Default Target 29 | # ------------------------------------------------------------------------------------------------- 30 | help: 31 | @echo "install locally into $(VENV)" 32 | @echo "clean Uninstall and clean directories" 33 | 34 | create-venv: 35 | ( \ 36 | python3 -m venv $(VENV) \ 37 | && source $(VENV)/bin/activate \ 38 | && python3 setup.py install; \ 39 | ) 40 | @echo 41 | @echo "--------------------------------------------------------------------------------" 42 | @echo 43 | @echo "!!! Run the following to enable the virtual env !!!" 44 | @echo 45 | @echo "source $(VENV)/bin/activate" 46 | 47 | clean: 48 | ( \ 49 | (source $(VENV)/bin/activate 2>/dev/null && deactivate) || true; \ 50 | rm -rf build/; \ 51 | rm -rf dist/; \ 52 | rm -rf $(VENV); \ 53 | rm -rf $(NAME).egg-info/; \ 54 | find . -type f -name '*.pyc' -exec rm {} \;; \ 55 | find . -type d -name '__pycache__' -prune -exec rmdir {} \;; \ 56 | ) 57 | @echo 58 | @echo "--------------------------------------------------------------------------------" 59 | @echo 60 | @echo "!!! Run the following to deactivate the virtual env !!!" 61 | @echo 62 | @echo "deactivate" 63 | 64 | 65 | # ------------------------------------------------------------------------------------------------- 66 | # Lint Targets 67 | # ------------------------------------------------------------------------------------------------- 68 | lint: _lint-files 69 | lint: _lint-version 70 | lint: _lint-name 71 | lint: _lint-description 72 | 73 | .PHONY: _lint-files 74 | _lint-files: 75 | @echo "# --------------------------------------------------------------------" 76 | @echo "# Lint files" 77 | @echo "# -------------------------------------------------------------------- #" 78 | @docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/file-lint:$(FL_VERSION) file-cr --text --ignore '$(FL_IGNORES)' --path . 79 | @docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/file-lint:$(FL_VERSION) file-crlf --text --ignore '$(FL_IGNORES)' --path . 80 | @docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/file-lint:$(FL_VERSION) file-trailing-single-newline --text --ignore '$(FL_IGNORES)' --path . 81 | @docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/file-lint:$(FL_VERSION) file-trailing-space --text --ignore '$(FL_IGNORES)' --path . 82 | @docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/file-lint:$(FL_VERSION) file-utf8 --text --ignore '$(FL_IGNORES)' --path . 83 | @docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/file-lint:$(FL_VERSION) file-utf8-bom --text --ignore '$(FL_IGNORES)' --path . 84 | @docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/file-lint:$(FL_VERSION) git-conflicts --text --ignore '$(FL_IGNORES)' --path . 85 | 86 | .PHONY: _lint-version 87 | _lint-version: 88 | @echo "# -------------------------------------------------------------------- #" 89 | @echo "# Check version" 90 | @echo "# -------------------------------------------------------------------- #" 91 | @VERSION_CODE=$$( cat redbox/defaults.py | grep ^DEF_VERSION | awk -F'"' '{print $$2}' ); \ 92 | VERSION_SETUP=$$( grep version= setup.py | awk -F'"' '{print $$2}' || true ); \ 93 | if [ "$${VERSION_CODE}" != "$${VERSION_SETUP}" ]; then \ 94 | echo "[ERROR] Version mismatch"; \ 95 | echo "redbox/defaults.py $${VERSION_CODE}"; \ 96 | echo "setup.py: $${VERSION_SETUP}"; \ 97 | exit 1; \ 98 | else \ 99 | echo "[OK] Version match"; \ 100 | echo "redbox/defaults.py $${VERSION_CODE}"; \ 101 | echo "setup.py: $${VERSION_SETUP}"; \ 102 | exit 0; \ 103 | fi \ 104 | 105 | .PHONY: _lint-name 106 | _lint-name: 107 | @echo "# -------------------------------------------------------------------- #" 108 | @echo "# Check name" 109 | @echo "# -------------------------------------------------------------------- #" 110 | @NAME_CODE=$$( cat redbox/defaults.py | grep ^DEF_NAME | awk -F'"' '{print $$2}' ); \ 111 | NAME_SETUP=$$( grep ':main' setup.py | awk -F"'" '{print $$2}' | awk -F'=' '{print $$1}' ); \ 112 | if [ "$${NAME_CODE}" != "$${NAME_SETUP}" ]; then \ 113 | echo "[ERROR] Name mismatch"; \ 114 | echo "redbox/defaults.py $${NAME_CODE}"; \ 115 | echo "setup.py: $${NAME_SETUP}"; \ 116 | exit 1; \ 117 | else \ 118 | echo "[OK] Name match"; \ 119 | echo "redbox/defaults.py $${NAME_CODE}"; \ 120 | echo "setup.py: $${NAME_SETUP}"; \ 121 | exit 0; \ 122 | fi \ 123 | 124 | .PHONY: _lint-description 125 | _lint-description: 126 | @echo "# -------------------------------------------------------------------- #" 127 | @echo "# Check description" 128 | @echo "# -------------------------------------------------------------------- #" 129 | @DESC_CODE=$$( cat redbox/defaults.py | grep ^DEF_DESC | awk -F'"' '{print $$2}' ); \ 130 | DESC_SETUP=$$( grep description= setup.py | awk -F'"' '{print $$2}' || true ); \ 131 | if [ "$${DESC_CODE}" != "$${DESC_SETUP}" ]; then \ 132 | echo "[ERROR] Desc mismatch"; \ 133 | echo "redbox/defaults.py $${DESC_CODE}"; \ 134 | echo "setup.py: $${DESC_SETUP}"; \ 135 | exit 1; \ 136 | else \ 137 | echo "[OK] Desc match"; \ 138 | echo "redbox/defaults.py $${DESC_CODE}"; \ 139 | echo "setup.py: $${DESC_SETUP}"; \ 140 | exit 0; \ 141 | fi \ 142 | 143 | 144 | # ------------------------------------------------------------------------------------------------- 145 | # Code Style Targets 146 | # ------------------------------------------------------------------------------------------------- 147 | code: _code-pycodestyle 148 | code: _code-pydocstyle 149 | code: _code-pylint 150 | code: _code-black 151 | code: _code-mypy 152 | 153 | .PHONY: _code-pycodestyle 154 | _code-pycodestyle: 155 | @echo "# -------------------------------------------------------------------- #" 156 | @echo "# Check pycodestyle" 157 | @echo "# -------------------------------------------------------------------- #" 158 | docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/pycodestyle --config=setup.cfg redbox/ 159 | 160 | .PHONY: _code-pydocstyle 161 | _code-pydocstyle: 162 | @echo "# -------------------------------------------------------------------- #" 163 | @echo "# Check pydocstyle" 164 | @echo "# -------------------------------------------------------------------- #" 165 | docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data cytopia/pydocstyle --explain --config=setup.cfg redbox/ 166 | 167 | .PHONY: _code-pylint 168 | _code-pylint: 169 | @echo "# -------------------------------------------------------------------- #" 170 | @echo "# Check pylint" 171 | @echo "# -------------------------------------------------------------------- #" 172 | docker run --rm $$(tty -s && echo "-it" || echo) -v $(PWD):/data --entrypoint=sh cytopia/pylint -c '\ 173 | pip3 install -r requirements.txt \ 174 | && pylint --rcfile=setup.cfg redbox/' 175 | 176 | .PHONY: _code-black 177 | _code-black: 178 | @echo "# -------------------------------------------------------------------- #" 179 | @echo "# Check Python Black" 180 | @echo "# -------------------------------------------------------------------- #" 181 | docker run --rm $$(tty -s && echo "-it" || echo) -v ${PWD}:/data cytopia/black -l 100 --check --diff redbox/ 182 | 183 | .PHONY: _code-mypy 184 | _code-mypy: 185 | @echo "# -------------------------------------------------------------------- #" 186 | @echo "# Check mypy" 187 | @echo "# -------------------------------------------------------------------- #" 188 | docker run --rm $$(tty -s && echo "-it" || echo) -v ${PWD}:/data cytopia/mypy --config-file setup.cfg redbox/ 189 | 190 | 191 | # ------------------------------------------------------------------------------------------------- 192 | # Build Targets 193 | # ------------------------------------------------------------------------------------------------- 194 | build: clean 195 | build: _lint-version 196 | build: _build-source_dist 197 | build: _build-binary_dist 198 | build: _build-python_package 199 | build: _build-check_python_package 200 | 201 | .PHONY: _build_source_dist 202 | _build-source_dist: 203 | @echo "Create source distribution" 204 | docker run \ 205 | --rm \ 206 | $$(tty -s && echo "-it" || echo) \ 207 | -v $(PWD):/data \ 208 | -w /data \ 209 | -u $$(id -u):$$(id -g) \ 210 | python:$(PYTHON_VERSION)-alpine \ 211 | python setup.py sdist 212 | 213 | .PHONY: _build_binary_dist 214 | _build-binary_dist: 215 | @echo "Create binary distribution" 216 | docker run \ 217 | --rm \ 218 | $$(tty -s && echo "-it" || echo) \ 219 | -v $(PWD):/data \ 220 | -w /data \ 221 | -u $$(id -u):$$(id -g) \ 222 | python:$(PYTHON_VERSION)-alpine \ 223 | python setup.py bdist_wheel --universal 224 | 225 | .PHONY: _build_python_package 226 | _build-python_package: 227 | @echo "Build Python package" 228 | docker run \ 229 | --rm \ 230 | $$(tty -s && echo "-it" || echo) \ 231 | -v $(PWD):/data \ 232 | -w /data \ 233 | -u $$(id -u):$$(id -g) \ 234 | python:$(PYTHON_VERSION)-alpine \ 235 | python setup.py build 236 | 237 | .PHONY: _build_check_python_package 238 | _build-check_python_package: 239 | @echo "Check Python package" 240 | docker run \ 241 | --rm \ 242 | $$(tty -s && echo "-it" || echo) \ 243 | -v $(PWD):/data \ 244 | -w /data \ 245 | python:$(PYTHON_VERSION)-slim \ 246 | sh -c "pip install twine \ 247 | && twine check dist/*" 248 | 249 | 250 | # ------------------------------------------------------------------------------------------------- 251 | # Publish Targets 252 | # ------------------------------------------------------------------------------------------------- 253 | deploy: _build-check_python_package 254 | docker run \ 255 | --rm \ 256 | $$(tty -s && echo "-it" || echo) \ 257 | -v $(PWD):/data \ 258 | -w /data \ 259 | python:$(PYTHON_VERSION)-slim \ 260 | sh -c "pip install twine \ 261 | && twine upload dist/*" 262 | 263 | 264 | # ------------------------------------------------------------------------------------------------- 265 | # Misc Targets 266 | # ------------------------------------------------------------------------------------------------- 267 | autoformat: 268 | docker run \ 269 | --rm \ 270 | $$(tty -s && echo "-it" || echo) \ 271 | -v $(PWD):/data \ 272 | -w /data \ 273 | cytopia/black -l 100 redbox/ 274 | -------------------------------------------------------------------------------- /example/volumes/grafana/provisioning/dashboards/dash-redbox.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | } 13 | ] 14 | }, 15 | "editable": true, 16 | "gnetId": null, 17 | "graphTooltip": 1, 18 | "id": 1, 19 | "iteration": 1614528053680, 20 | "links": [], 21 | "panels": [ 22 | { 23 | "datasource": null, 24 | "fieldConfig": { 25 | "defaults": { 26 | "color": { 27 | "mode": "thresholds" 28 | }, 29 | "custom": {}, 30 | "decimals": 2, 31 | "mappings": [], 32 | "max": 100, 33 | "min": 0, 34 | "thresholds": { 35 | "mode": "absolute", 36 | "steps": [ 37 | { 38 | "color": "red", 39 | "value": null 40 | }, 41 | { 42 | "color": "#EAB839", 43 | "value": 99 44 | }, 45 | { 46 | "color": "green", 47 | "value": 100 48 | } 49 | ] 50 | }, 51 | "unit": "percent" 52 | }, 53 | "overrides": [] 54 | }, 55 | "gridPos": { 56 | "h": 8, 57 | "w": 12, 58 | "x": 0, 59 | "y": 0 60 | }, 61 | "id": 5, 62 | "options": { 63 | "reduceOptions": { 64 | "calcs": [ 65 | "mean" 66 | ], 67 | "fields": "", 68 | "values": false 69 | }, 70 | "showThresholdLabels": false, 71 | "showThresholdMarkers": true, 72 | "text": {} 73 | }, 74 | "pluginVersion": "7.4.2", 75 | "targets": [ 76 | { 77 | "expr": "100 * (sum by (name) (redbox_success{group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})) / ((sum by (name) (redbox_success{group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"}) + sum by (name) (redbox_failure{group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})))", 78 | "instant": false, 79 | "interval": "", 80 | "legendFormat": "{{ name }}", 81 | "refId": "A" 82 | } 83 | ], 84 | "title": "Availability", 85 | "transparent": true, 86 | "type": "gauge" 87 | }, 88 | { 89 | "aliasColors": {}, 90 | "bars": true, 91 | "dashLength": 10, 92 | "dashes": false, 93 | "datasource": null, 94 | "description": "", 95 | "fieldConfig": { 96 | "defaults": { 97 | "custom": {} 98 | }, 99 | "overrides": [] 100 | }, 101 | "fill": 1, 102 | "fillGradient": 0, 103 | "gridPos": { 104 | "h": 8, 105 | "w": 12, 106 | "x": 12, 107 | "y": 0 108 | }, 109 | "hiddenSeries": false, 110 | "id": 11, 111 | "legend": { 112 | "alignAsTable": false, 113 | "avg": false, 114 | "current": false, 115 | "max": false, 116 | "min": false, 117 | "rightSide": false, 118 | "show": true, 119 | "total": false, 120 | "values": false 121 | }, 122 | "lines": false, 123 | "linewidth": 1, 124 | "nullPointMode": "null", 125 | "options": { 126 | "alertThreshold": false 127 | }, 128 | "percentage": false, 129 | "pluginVersion": "7.4.2", 130 | "pointradius": 2, 131 | "points": false, 132 | "renderer": "flot", 133 | "seriesOverrides": [], 134 | "spaceLength": 10, 135 | "stack": true, 136 | "steppedLine": false, 137 | "targets": [ 138 | { 139 | "expr": "max by (name, err_msg) (redbox_failure{group_group=~\"$group\",group_name=~\"$name\",success=\"0\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})", 140 | "interval": "", 141 | "legendFormat": "{{ name }}", 142 | "refId": "A" 143 | } 144 | ], 145 | "thresholds": [ 146 | { 147 | "$$hashKey": "object:868", 148 | "colorMode": "critical", 149 | "fill": true, 150 | "line": true, 151 | "op": "gt", 152 | "value": 0.9999, 153 | "yaxis": "left" 154 | } 155 | ], 156 | "timeFrom": null, 157 | "timeRegions": [], 158 | "timeShift": null, 159 | "title": "Number of Websites down", 160 | "tooltip": { 161 | "shared": true, 162 | "sort": 0, 163 | "value_type": "individual" 164 | }, 165 | "transparent": true, 166 | "type": "graph", 167 | "xaxis": { 168 | "buckets": null, 169 | "mode": "time", 170 | "name": null, 171 | "show": true, 172 | "values": [] 173 | }, 174 | "yaxes": [ 175 | { 176 | "$$hashKey": "object:155", 177 | "decimals": 0, 178 | "format": "short", 179 | "label": "Websites", 180 | "logBase": 1, 181 | "max": null, 182 | "min": "0", 183 | "show": true 184 | }, 185 | { 186 | "$$hashKey": "object:156", 187 | "format": "none", 188 | "label": null, 189 | "logBase": 1, 190 | "max": null, 191 | "min": null, 192 | "show": true 193 | } 194 | ], 195 | "yaxis": { 196 | "align": false, 197 | "alignLevel": null 198 | } 199 | }, 200 | { 201 | "datasource": null, 202 | "description": "", 203 | "fieldConfig": { 204 | "defaults": { 205 | "color": { 206 | "mode": "thresholds" 207 | }, 208 | "custom": { 209 | "align": "left", 210 | "displayMode": "auto", 211 | "filterable": false 212 | }, 213 | "mappings": [ 214 | { 215 | "from": "", 216 | "id": 1, 217 | "text": "", 218 | "to": "", 219 | "type": 1, 220 | "value": "" 221 | } 222 | ], 223 | "thresholds": { 224 | "mode": "absolute", 225 | "steps": [ 226 | { 227 | "color": "green", 228 | "value": null 229 | }, 230 | { 231 | "color": "red", 232 | "value": 80 233 | } 234 | ] 235 | }, 236 | "unit": "short" 237 | }, 238 | "overrides": [ 239 | { 240 | "matcher": { 241 | "id": "byName", 242 | "options": "Downtime" 243 | }, 244 | "properties": [ 245 | { 246 | "id": "unit", 247 | "value": "s" 248 | } 249 | ] 250 | }, 251 | { 252 | "matcher": { 253 | "id": "byName", 254 | "options": "status_code" 255 | }, 256 | "properties": [ 257 | { 258 | "id": "custom.width", 259 | "value": 146 260 | } 261 | ] 262 | }, 263 | { 264 | "matcher": { 265 | "id": "byName", 266 | "options": "name" 267 | }, 268 | "properties": [ 269 | { 270 | "id": "custom.width", 271 | "value": 181 272 | } 273 | ] 274 | } 275 | ] 276 | }, 277 | "gridPos": { 278 | "h": 6, 279 | "w": 12, 280 | "x": 0, 281 | "y": 8 282 | }, 283 | "id": 17, 284 | "interval": null, 285 | "maxDataPoints": null, 286 | "options": { 287 | "showHeader": true, 288 | "sortBy": [] 289 | }, 290 | "pluginVersion": "7.4.2", 291 | "targets": [ 292 | { 293 | "expr": "group by (name, status_code, extract, err_msg) (redbox_failure{success=\"0\", group_group=~\"$group\", group_name=~\"$name\", group_env=~\"$env\", group_country=~\"$country\", group_page=~\"$page\"})", 294 | "format": "table", 295 | "instant": false, 296 | "interval": "", 297 | "intervalFactor": 1, 298 | "legendFormat": "", 299 | "refId": "A" 300 | } 301 | ], 302 | "timeFrom": null, 303 | "title": "Errors", 304 | "transformations": [ 305 | { 306 | "id": "groupBy", 307 | "options": { 308 | "fields": { 309 | "Value": { 310 | "aggregations": [], 311 | "operation": "aggregate" 312 | }, 313 | "err_msg": { 314 | "aggregations": [], 315 | "operation": "groupby" 316 | }, 317 | "extract": { 318 | "aggregations": [], 319 | "operation": "groupby" 320 | }, 321 | "name": { 322 | "aggregations": [], 323 | "operation": "groupby" 324 | }, 325 | "status_code": { 326 | "aggregations": [], 327 | "operation": "groupby" 328 | } 329 | } 330 | } 331 | }, 332 | { 333 | "id": "organize", 334 | "options": { 335 | "excludeByName": { 336 | "err_msg": false 337 | }, 338 | "indexByName": { 339 | "err_msg": 3, 340 | "extract": 2, 341 | "name": 0, 342 | "status_code": 1 343 | }, 344 | "renameByName": { 345 | "Value (sum)": "Downtime", 346 | "err_msg": "" 347 | } 348 | } 349 | } 350 | ], 351 | "transparent": true, 352 | "type": "table" 353 | }, 354 | { 355 | "aliasColors": {}, 356 | "bars": false, 357 | "dashLength": 10, 358 | "dashes": false, 359 | "datasource": null, 360 | "fieldConfig": { 361 | "defaults": { 362 | "custom": {} 363 | }, 364 | "overrides": [] 365 | }, 366 | "fill": 1, 367 | "fillGradient": 0, 368 | "gridPos": { 369 | "h": 6, 370 | "w": 12, 371 | "x": 12, 372 | "y": 8 373 | }, 374 | "hiddenSeries": false, 375 | "id": 9, 376 | "legend": { 377 | "alignAsTable": false, 378 | "avg": false, 379 | "current": false, 380 | "hideEmpty": true, 381 | "max": false, 382 | "min": false, 383 | "rightSide": false, 384 | "show": true, 385 | "sideWidth": null, 386 | "total": false, 387 | "values": false 388 | }, 389 | "lines": true, 390 | "linewidth": 1, 391 | "nullPointMode": "null", 392 | "options": { 393 | "alertThreshold": false 394 | }, 395 | "percentage": false, 396 | "pluginVersion": "7.4.2", 397 | "pointradius": 3, 398 | "points": true, 399 | "renderer": "flot", 400 | "seriesOverrides": [], 401 | "spaceLength": 10, 402 | "stack": false, 403 | "steppedLine": false, 404 | "targets": [ 405 | { 406 | "exemplar": false, 407 | "expr": "max by (name, status_code) (redbox_status_code{success=\"0\",group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})", 408 | "instant": false, 409 | "interval": "", 410 | "legendFormat": "{{ name }}: {{ status_code }}", 411 | "refId": "A" 412 | } 413 | ], 414 | "thresholds": [], 415 | "timeFrom": null, 416 | "timeRegions": [], 417 | "timeShift": null, 418 | "title": "HTTP Status Code of down Websites", 419 | "tooltip": { 420 | "shared": true, 421 | "sort": 0, 422 | "value_type": "individual" 423 | }, 424 | "transparent": true, 425 | "type": "graph", 426 | "xaxis": { 427 | "buckets": null, 428 | "mode": "time", 429 | "name": null, 430 | "show": true, 431 | "values": [] 432 | }, 433 | "yaxes": [ 434 | { 435 | "$$hashKey": "object:155", 436 | "decimals": 0, 437 | "format": "short", 438 | "label": "Status Code", 439 | "logBase": 1, 440 | "max": "600", 441 | "min": "0", 442 | "show": true 443 | }, 444 | { 445 | "$$hashKey": "object:156", 446 | "format": "short", 447 | "label": null, 448 | "logBase": 1, 449 | "max": null, 450 | "min": null, 451 | "show": true 452 | } 453 | ], 454 | "yaxis": { 455 | "align": false, 456 | "alignLevel": null 457 | } 458 | }, 459 | { 460 | "aliasColors": {}, 461 | "bars": false, 462 | "dashLength": 10, 463 | "dashes": false, 464 | "datasource": null, 465 | "fieldConfig": { 466 | "defaults": { 467 | "custom": {}, 468 | "unit": "s" 469 | }, 470 | "overrides": [] 471 | }, 472 | "fill": 1, 473 | "fillGradient": 0, 474 | "gridPos": { 475 | "h": 11, 476 | "w": 8, 477 | "x": 0, 478 | "y": 14 479 | }, 480 | "hiddenSeries": false, 481 | "id": 2, 482 | "legend": { 483 | "alignAsTable": true, 484 | "avg": true, 485 | "current": true, 486 | "hideEmpty": false, 487 | "hideZero": false, 488 | "max": true, 489 | "min": true, 490 | "rightSide": false, 491 | "show": true, 492 | "sort": "current", 493 | "sortDesc": true, 494 | "total": false, 495 | "values": true 496 | }, 497 | "lines": true, 498 | "linewidth": 1, 499 | "nullPointMode": "null", 500 | "options": { 501 | "alertThreshold": true 502 | }, 503 | "percentage": false, 504 | "pluginVersion": "7.4.2", 505 | "pointradius": 2, 506 | "points": false, 507 | "renderer": "flot", 508 | "seriesOverrides": [], 509 | "spaceLength": 10, 510 | "stack": false, 511 | "steppedLine": false, 512 | "targets": [ 513 | { 514 | "expr": "max by (name) (redbox_time_total{success=\"1\",group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})", 515 | "instant": false, 516 | "interval": "", 517 | "legendFormat": "{{ name }}", 518 | "refId": "A" 519 | } 520 | ], 521 | "thresholds": [ 522 | { 523 | "$$hashKey": "object:363", 524 | "colorMode": "critical", 525 | "fill": true, 526 | "line": true, 527 | "op": "gt", 528 | "yaxis": "left" 529 | } 530 | ], 531 | "timeFrom": null, 532 | "timeRegions": [], 533 | "timeShift": null, 534 | "title": "Total Time (OK Websites)", 535 | "tooltip": { 536 | "shared": true, 537 | "sort": 0, 538 | "value_type": "individual" 539 | }, 540 | "transparent": true, 541 | "type": "graph", 542 | "xaxis": { 543 | "buckets": null, 544 | "mode": "time", 545 | "name": null, 546 | "show": true, 547 | "values": [] 548 | }, 549 | "yaxes": [ 550 | { 551 | "$$hashKey": "object:180", 552 | "format": "s", 553 | "label": "", 554 | "logBase": 1, 555 | "max": null, 556 | "min": null, 557 | "show": true 558 | }, 559 | { 560 | "$$hashKey": "object:181", 561 | "format": "short", 562 | "label": null, 563 | "logBase": 1, 564 | "max": null, 565 | "min": null, 566 | "show": true 567 | } 568 | ], 569 | "yaxis": { 570 | "align": false, 571 | "alignLevel": null 572 | } 573 | }, 574 | { 575 | "aliasColors": {}, 576 | "bars": false, 577 | "dashLength": 10, 578 | "dashes": false, 579 | "datasource": null, 580 | "fieldConfig": { 581 | "defaults": { 582 | "custom": {} 583 | }, 584 | "overrides": [] 585 | }, 586 | "fill": 1, 587 | "fillGradient": 0, 588 | "gridPos": { 589 | "h": 11, 590 | "w": 8, 591 | "x": 8, 592 | "y": 14 593 | }, 594 | "hiddenSeries": false, 595 | "id": 19, 596 | "legend": { 597 | "alignAsTable": true, 598 | "avg": true, 599 | "current": true, 600 | "max": true, 601 | "min": true, 602 | "show": true, 603 | "sort": "current", 604 | "sortDesc": true, 605 | "total": false, 606 | "values": true 607 | }, 608 | "lines": true, 609 | "linewidth": 1, 610 | "nullPointMode": "null", 611 | "options": { 612 | "alertThreshold": true 613 | }, 614 | "percentage": false, 615 | "pluginVersion": "7.4.2", 616 | "pointradius": 2, 617 | "points": false, 618 | "renderer": "flot", 619 | "seriesOverrides": [], 620 | "spaceLength": 10, 621 | "stack": false, 622 | "steppedLine": false, 623 | "targets": [ 624 | { 625 | "expr": "max by (name) (redbox_time_ttfb{success=\"1\",group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})", 626 | "interval": "", 627 | "legendFormat": "{{ name }}", 628 | "refId": "A" 629 | } 630 | ], 631 | "thresholds": [], 632 | "timeFrom": null, 633 | "timeRegions": [], 634 | "timeShift": null, 635 | "title": "Response Time: Time To First Byte (OK Websites)", 636 | "tooltip": { 637 | "shared": true, 638 | "sort": 0, 639 | "value_type": "individual" 640 | }, 641 | "transparent": true, 642 | "type": "graph", 643 | "xaxis": { 644 | "buckets": null, 645 | "mode": "time", 646 | "name": null, 647 | "show": true, 648 | "values": [] 649 | }, 650 | "yaxes": [ 651 | { 652 | "$$hashKey": "object:508", 653 | "format": "s", 654 | "label": null, 655 | "logBase": 1, 656 | "max": null, 657 | "min": null, 658 | "show": true 659 | }, 660 | { 661 | "$$hashKey": "object:509", 662 | "format": "short", 663 | "label": null, 664 | "logBase": 1, 665 | "max": null, 666 | "min": null, 667 | "show": true 668 | } 669 | ], 670 | "yaxis": { 671 | "align": false, 672 | "alignLevel": null 673 | } 674 | }, 675 | { 676 | "aliasColors": {}, 677 | "bars": false, 678 | "dashLength": 10, 679 | "dashes": false, 680 | "datasource": null, 681 | "fieldConfig": { 682 | "defaults": { 683 | "custom": {} 684 | }, 685 | "overrides": [] 686 | }, 687 | "fill": 1, 688 | "fillGradient": 0, 689 | "gridPos": { 690 | "h": 11, 691 | "w": 8, 692 | "x": 16, 693 | "y": 14 694 | }, 695 | "hiddenSeries": false, 696 | "id": 21, 697 | "legend": { 698 | "alignAsTable": true, 699 | "avg": true, 700 | "current": true, 701 | "max": true, 702 | "min": true, 703 | "show": true, 704 | "sort": "current", 705 | "sortDesc": true, 706 | "total": false, 707 | "values": true 708 | }, 709 | "lines": true, 710 | "linewidth": 1, 711 | "nullPointMode": "null", 712 | "options": { 713 | "alertThreshold": true 714 | }, 715 | "percentage": false, 716 | "pluginVersion": "7.4.2", 717 | "pointradius": 2, 718 | "points": false, 719 | "renderer": "flot", 720 | "seriesOverrides": [], 721 | "spaceLength": 10, 722 | "stack": false, 723 | "steppedLine": false, 724 | "targets": [ 725 | { 726 | "expr": "max by (name) (redbox_time_download{success=\"1\",group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})", 727 | "interval": "", 728 | "legendFormat": "{{ name }}", 729 | "refId": "A" 730 | } 731 | ], 732 | "thresholds": [], 733 | "timeFrom": null, 734 | "timeRegions": [], 735 | "timeShift": null, 736 | "title": "Download Time (OK Websites)", 737 | "tooltip": { 738 | "shared": true, 739 | "sort": 0, 740 | "value_type": "individual" 741 | }, 742 | "transparent": true, 743 | "type": "graph", 744 | "xaxis": { 745 | "buckets": null, 746 | "mode": "time", 747 | "name": null, 748 | "show": true, 749 | "values": [] 750 | }, 751 | "yaxes": [ 752 | { 753 | "$$hashKey": "object:924", 754 | "format": "s", 755 | "label": null, 756 | "logBase": 1, 757 | "max": null, 758 | "min": null, 759 | "show": true 760 | }, 761 | { 762 | "$$hashKey": "object:925", 763 | "format": "short", 764 | "label": null, 765 | "logBase": 1, 766 | "max": null, 767 | "min": null, 768 | "show": true 769 | } 770 | ], 771 | "yaxis": { 772 | "align": false, 773 | "alignLevel": null 774 | } 775 | }, 776 | { 777 | "aliasColors": {}, 778 | "bars": false, 779 | "dashLength": 10, 780 | "dashes": false, 781 | "datasource": null, 782 | "fieldConfig": { 783 | "defaults": { 784 | "custom": {} 785 | }, 786 | "overrides": [] 787 | }, 788 | "fill": 1, 789 | "fillGradient": 0, 790 | "gridPos": { 791 | "h": 11, 792 | "w": 12, 793 | "x": 12, 794 | "y": 25 795 | }, 796 | "hiddenSeries": false, 797 | "id": 7, 798 | "legend": { 799 | "alignAsTable": true, 800 | "avg": false, 801 | "current": true, 802 | "max": true, 803 | "min": true, 804 | "rightSide": false, 805 | "show": true, 806 | "sort": "max", 807 | "sortDesc": true, 808 | "total": false, 809 | "values": true 810 | }, 811 | "lines": true, 812 | "linewidth": 1, 813 | "nullPointMode": "null", 814 | "options": { 815 | "alertThreshold": true 816 | }, 817 | "percentage": false, 818 | "pluginVersion": "7.4.2", 819 | "pointradius": 2, 820 | "points": false, 821 | "renderer": "flot", 822 | "seriesOverrides": [], 823 | "spaceLength": 10, 824 | "stack": false, 825 | "steppedLine": false, 826 | "targets": [ 827 | { 828 | "expr": "max by (name) (redbox_content_size{success=\"1\",group_group=~\"$group\",group_name=~\"$name\",group_env=~\"$env\",group_country=~\"$country\",group_page=~\"$page\"})", 829 | "interval": "", 830 | "legendFormat": "{{ name }}", 831 | "refId": "A" 832 | } 833 | ], 834 | "thresholds": [], 835 | "timeFrom": null, 836 | "timeRegions": [], 837 | "timeShift": null, 838 | "title": "Content Size (OK Websites)", 839 | "tooltip": { 840 | "shared": true, 841 | "sort": 0, 842 | "value_type": "individual" 843 | }, 844 | "transparent": true, 845 | "type": "graph", 846 | "xaxis": { 847 | "buckets": null, 848 | "mode": "time", 849 | "name": null, 850 | "show": true, 851 | "values": [] 852 | }, 853 | "yaxes": [ 854 | { 855 | "$$hashKey": "object:154", 856 | "format": "decbytes", 857 | "label": null, 858 | "logBase": 1, 859 | "max": null, 860 | "min": null, 861 | "show": true 862 | }, 863 | { 864 | "$$hashKey": "object:155", 865 | "format": "short", 866 | "label": null, 867 | "logBase": 1, 868 | "max": null, 869 | "min": null, 870 | "show": true 871 | } 872 | ], 873 | "yaxis": { 874 | "align": false, 875 | "alignLevel": null 876 | } 877 | } 878 | ], 879 | "refresh": "5s", 880 | "schemaVersion": 27, 881 | "style": "dark", 882 | "tags": [], 883 | "templating": { 884 | "list": [ 885 | { 886 | "allValue": null, 887 | "current": { 888 | "selected": false, 889 | "text": "All", 890 | "value": "$__all" 891 | }, 892 | "datasource": null, 893 | "definition": "redbox_success", 894 | "description": null, 895 | "error": null, 896 | "hide": 0, 897 | "includeAll": true, 898 | "label": "Environment", 899 | "multi": false, 900 | "name": "env", 901 | "options": [], 902 | "query": { 903 | "query": "redbox_success", 904 | "refId": "StandardVariableQuery" 905 | }, 906 | "refresh": 1, 907 | "regex": "/.*group_env=\"(?.+?)\".*/", 908 | "skipUrlSync": false, 909 | "sort": 1, 910 | "tagValuesQuery": "", 911 | "tags": [], 912 | "tagsQuery": "", 913 | "type": "query", 914 | "useTags": false 915 | }, 916 | { 917 | "allValue": null, 918 | "current": { 919 | "selected": false, 920 | "text": "All", 921 | "value": "$__all" 922 | }, 923 | "datasource": null, 924 | "definition": "redbox_success", 925 | "description": null, 926 | "error": null, 927 | "hide": 0, 928 | "includeAll": true, 929 | "label": "Country", 930 | "multi": false, 931 | "name": "country", 932 | "options": [], 933 | "query": { 934 | "query": "redbox_success", 935 | "refId": "StandardVariableQuery" 936 | }, 937 | "refresh": 1, 938 | "regex": "/.*group_country=\"(?.+?)\".*/", 939 | "skipUrlSync": false, 940 | "sort": 1, 941 | "tagValuesQuery": "", 942 | "tags": [], 943 | "tagsQuery": "", 944 | "type": "query", 945 | "useTags": false 946 | }, 947 | { 948 | "allValue": null, 949 | "current": { 950 | "selected": false, 951 | "text": "All", 952 | "value": "$__all" 953 | }, 954 | "datasource": null, 955 | "definition": "redbox_success", 956 | "description": null, 957 | "error": null, 958 | "hide": 0, 959 | "includeAll": true, 960 | "label": "Page", 961 | "multi": false, 962 | "name": "page", 963 | "options": [], 964 | "query": { 965 | "query": "redbox_success", 966 | "refId": "StandardVariableQuery" 967 | }, 968 | "refresh": 1, 969 | "regex": "/.*group_page=\"(?.+?)\".*/", 970 | "skipUrlSync": false, 971 | "sort": 1, 972 | "tagValuesQuery": "", 973 | "tags": [], 974 | "tagsQuery": "", 975 | "type": "query", 976 | "useTags": false 977 | }, 978 | { 979 | "allValue": null, 980 | "current": { 981 | "selected": false, 982 | "text": "All", 983 | "value": "$__all" 984 | }, 985 | "datasource": null, 986 | "definition": "redbox_success", 987 | "description": null, 988 | "error": null, 989 | "hide": 0, 990 | "includeAll": true, 991 | "label": "Name", 992 | "multi": false, 993 | "name": "name", 994 | "options": [], 995 | "query": { 996 | "query": "redbox_success", 997 | "refId": "StandardVariableQuery" 998 | }, 999 | "refresh": 1, 1000 | "regex": "/.*group_name=\"(?.+?)\".*/", 1001 | "skipUrlSync": false, 1002 | "sort": 1, 1003 | "tagValuesQuery": "", 1004 | "tags": [], 1005 | "tagsQuery": "", 1006 | "type": "query", 1007 | "useTags": false 1008 | }, 1009 | { 1010 | "allValue": null, 1011 | "current": { 1012 | "selected": true, 1013 | "text": "All", 1014 | "value": "$__all" 1015 | }, 1016 | "datasource": null, 1017 | "definition": "redbox_success", 1018 | "description": null, 1019 | "error": null, 1020 | "hide": 0, 1021 | "includeAll": true, 1022 | "label": "Group", 1023 | "multi": false, 1024 | "name": "group", 1025 | "options": [], 1026 | "query": { 1027 | "query": "redbox_success", 1028 | "refId": "StandardVariableQuery" 1029 | }, 1030 | "refresh": 1, 1031 | "regex": "/.*group_group=\"(?.+?)\".*/", 1032 | "skipUrlSync": false, 1033 | "sort": 1, 1034 | "tagValuesQuery": "", 1035 | "tags": [], 1036 | "tagsQuery": "", 1037 | "type": "query", 1038 | "useTags": false 1039 | } 1040 | ] 1041 | }, 1042 | "time": { 1043 | "from": "now-15m", 1044 | "to": "now" 1045 | }, 1046 | "timepicker": { 1047 | "hidden": false 1048 | }, 1049 | "timezone": "", 1050 | "title": "HTTP Status (redbox_exporter)", 1051 | "uid": "3W6b3jPGz", 1052 | "version": 11 1053 | } 1054 | --------------------------------------------------------------------------------