├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── mypy.ini ├── requirements.txt ├── setup.py ├── starlette_exporter ├── __init__.py ├── labels.py ├── middleware.py ├── optional_metrics.py └── py.typed └── tests ├── __init__.py ├── static └── test.txt └── test_middleware.py /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Python unit tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Set up Python 3.12 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.12 21 | - name: Lint with flake8 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install flake8 25 | # stop the build if there are Python syntax errors or undefined names 26 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 27 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 28 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 29 | 30 | mypy: 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Set up Python 3.12 36 | uses: actions/setup-python@v2 37 | with: 38 | python-version: 3.12 39 | - name: Type checking with mypy 40 | run: | 41 | python -m pip install --upgrade pip 42 | pip install mypy 43 | mypy starlette_exporter 44 | 45 | test: 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 50 | 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - uses: actions/checkout@v1 55 | - name: Set up Python ${{ matrix.python-version }} 56 | uses: actions/setup-python@v2 57 | with: 58 | python-version: ${{ matrix.python-version }} 59 | - name: Install dependencies 60 | run: | 61 | python -m pip install --upgrade pip 62 | pip install -r requirements.txt 63 | - name: Test with pytest 64 | run: | 65 | pip install pytest 66 | PYTHONPATH=. pytest 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # VS Code project settings 101 | .vscode 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # starlette_exporter 2 | 3 | ## Prometheus exporter for Starlette and FastAPI 4 | 5 | starlette_exporter collects basic metrics for Starlette and FastAPI based applications: 6 | 7 | * starlette_requests_total: a counter representing the total requests 8 | * starlette_request_duration_seconds: a histogram representing the distribution of request response times 9 | * starlette_requests_in_progress: a gauge that keeps track of how many concurrent requests are being processed 10 | 11 | Metrics include labels for the HTTP method, the path, and the response status code. 12 | 13 | ``` 14 | starlette_requests_total{method="GET",path="/",status_code="200"} 1.0 15 | starlette_request_duration_seconds_bucket{le="0.01",method="GET",path="/",status_code="200"} 1.0 16 | ``` 17 | 18 | Use the HTTP handler `handle_metrics` at path `/metrics` to expose a metrics endpoint to Prometheus. 19 | 20 | ## Table of Contents 21 | 22 | * [starlette_exporter](#starlette_exporter) 23 | * [Prometheus exporter for Starlette and FastAPI](#prometheus-exporter-for-starlette-and-fastapi) 24 | * [Table of Contents](#table-of-contents) 25 | * [Usage](#usage) 26 | * [Starlette](#starlette) 27 | * [FastAPI](#fastapi) 28 | * [Options](#options) 29 | * [Labels](#labels) 30 | * [Exemplars](#exemplars) 31 | * [Custom Metrics](#custom-metrics) 32 | * [Multiprocess mode (gunicorn deployments)](#multiprocess-mode-gunicorn-deployments) 33 | * [Developing](#developing) 34 | * [License](#license) 35 | * [Dependencies](#dependencies) 36 | * [Credits](#credits) 37 | 38 | ## Usage 39 | 40 | ```sh 41 | pip install starlette_exporter 42 | ``` 43 | 44 | ### Starlette 45 | 46 | ```python 47 | from starlette.applications import Starlette 48 | from starlette_exporter import PrometheusMiddleware, handle_metrics 49 | 50 | app = Starlette() 51 | app.add_middleware(PrometheusMiddleware) 52 | app.add_route("/metrics", handle_metrics) 53 | 54 | ... 55 | ``` 56 | 57 | ### FastAPI 58 | 59 | ```python 60 | from fastapi import FastAPI 61 | from starlette_exporter import PrometheusMiddleware, handle_metrics 62 | 63 | app = FastAPI() 64 | app.add_middleware(PrometheusMiddleware) 65 | app.add_route("/metrics", handle_metrics) 66 | 67 | ... 68 | ``` 69 | 70 | ## Options 71 | 72 | `app_name`: Sets the value of the `app_name` label for exported metrics (default: `starlette`). 73 | 74 | `prefix`: Sets the prefix of the exported metric names (default: `starlette`). 75 | 76 | `labels`: Optional dict containing default labels that will be added to all metrics. The values can be either a static value or a callback function that 77 | retrieves a value from the `Request` object. [See below](#labels) for examples. 78 | 79 | `exemplars`: Optional dict containing label/value pairs. The "value" should be a callback function that returns the desired value at runtime. 80 | 81 | `group_paths`: Populate the path label using named parameters (if any) in the router path, e.g. `/api/v1/items/{item_id}`. This will group requests together by endpoint (regardless of the value of `item_id`). As of v0.18.0, the default is `True`, and changing to `False` is highly discouraged (see [warnings about cardinality](https://grafana.com/blog/2022/02/15/what-are-cardinality-spikes-and-why-do-they-matter/)). 82 | 83 | `filter_unhandled_paths`: setting this to `True` will cause the middleware to ignore requests with unhandled paths (in other words, 404 errors). This helps prevent filling up the metrics with 404 errors and/or intentially bad requests. Default is `True`. 84 | 85 | `group_unhandled_paths`: Similar to `filter_unhandled_paths`, but instead of ignoring the requests, they are grouped under the `__unknown__` path. This option overrides `filter_unhandled_paths` by setting it to `False`. The default value is `False`. 86 | 87 | `buckets`: accepts an optional list of numbers to use as histogram buckets. The default value is `None`, which will cause the library to fall back on the Prometheus defaults (currently `[0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0]`). 88 | 89 | `skip_paths`: accepts an optional list of paths, or regular expressions for paths, that will not collect metrics. The default value is `None`, which will cause the library to collect metrics on every requested path. This option is useful to avoid collecting metrics on health check, readiness or liveness probe endpoints. 90 | 91 | `skip_methods`: accepts an optional list of methods that will not collect metrics. The default value is `None`, which will cause the library to collect request metrics with each method. This option is useful to avoid collecting metrics on requests related to the communication description for endpoints. 92 | 93 | `always_use_int_status`: accepts a boolean. The default value is False. If set to True the libary will attempt to convert the `status_code` value to an integer (e.g. if you are using HTTPStatus, HTTPStatus.OK will become 200 for all metrics). 94 | 95 | `optional_metrics`: a list of pre-defined metrics that can be optionally added to the default metrics. The following optional metrics are available: 96 | 97 | * `response_body_size`: a counter that tracks the size of response bodies for each endpoint 98 | 99 | For optional metric examples, [see below](#optional-metrics). 100 | 101 | Full example: 102 | 103 | ```python 104 | app.add_middleware( 105 | PrometheusMiddleware, 106 | app_name="hello_world", 107 | prefix='myapp', 108 | labels={ 109 | "server_name": os.getenv("HOSTNAME"), 110 | }), 111 | buckets=[0.1, 0.25, 0.5], 112 | skip_paths=['/health'], 113 | skip_methods=['OPTIONS'], 114 | always_use_int_status=False), 115 | exemplars=lambda: {"trace_id": get_trace_id} # function that returns a trace id 116 | ``` 117 | 118 | ## Labels 119 | 120 | The included metrics have built-in default labels such as `app_name`, `method`, `path`, and `status_code`. Additional default labels can be 121 | added by passing a dictionary to the `labels` arg to `PrometheusMiddleware`. Each label's value can be either a static 122 | value or, optionally, a callback function. The built-in default label names are reserved and cannot be reused. 123 | 124 | If a callback function is used, it will receive the Request instance as its argument. 125 | 126 | ```python 127 | app.add_middleware( 128 | PrometheusMiddleware, 129 | labels={ 130 | "service": "api", 131 | "env": os.getenv("ENV") 132 | } 133 | ``` 134 | 135 | Ensure that label names follow [Prometheus naming conventions](https://prometheus.io/docs/practices/naming/) and that label 136 | values are constrained (see [this writeup from Grafana on cardinality](https://grafana.com/blog/2022/02/15/what-are-cardinality-spikes-and-why-do-they-matter/)). 137 | 138 | ### Label helpers 139 | 140 | **`from_header(key: string, allowed_values: Optional[Iterable] = None, default: str = "")`**: a convenience function for using a header value as a label. 141 | 142 | `allowed_values` allows you to supply a list of allowed values. If supplied, header values not in the list will result in 143 | an empty string being returned. This allows you to constrain the label values, reducing the risk of excessive cardinality. 144 | 145 | `default`: the default value if the header does not exist. 146 | 147 | Do not use headers that could contain unconstrained values (e.g. user id) or user-supplied values. 148 | 149 | 150 | **`from_response_header(key: str, allowed_values: Optional[Iterable] = None, default: str = "")`**: a helper 151 | function that extracts a value from a response header. This may be useful if you are using a middleware 152 | or decorator that populates a header. 153 | 154 | The same options (and warnings) apply as the `from_header` function. 155 | 156 | ```python 157 | from starlette_exporter import PrometheusMiddleware, from_header 158 | 159 | app.add_middleware( 160 | PrometheusMiddleware, 161 | labels={ 162 | "host": from_header("X-Internal-Org", allowed_values=("accounting", "marketing", "product")) 163 | "cache": from_response_header("X-FastAPI-Cache", allowed_values=("hit", "miss")) 164 | } 165 | ``` 166 | 167 | ## Exemplars 168 | 169 | Exemplars are used for labeling histogram observations or counter increments with a trace id. This allows adding 170 | trace ids to your charts (for example, latency graphs could include traces corresponding to various latency buckets). 171 | 172 | To add exemplars to `starlette_exporter` metrics, pass a dict to the PrometheusMiddleware class with label as well as 173 | a callback function that returns a string (typically the current trace id). 174 | 175 | **Example:** 176 | 177 | ```python 178 | # must use `handle_openmetrics` instead of `handle_metrics` for exemplars to appear in /metrics output. 179 | from starlette_exporter import PrometheusMiddleware, handle_openmetrics 180 | 181 | app.add_middleware( 182 | PrometheusMiddleware, 183 | exemplars=lambda: {"trace_id": get_trace_id} # supply your own callback function 184 | ) 185 | 186 | app.add_route("/metrics", handle_openmetrics) 187 | ``` 188 | 189 | Exemplars are only supported by the openmetrics-text exposition format. A new `handle_openmetrics` handler function is provided 190 | (see above example). 191 | 192 | For more information, see the [Grafana exemplar documentation](https://grafana.com/docs/grafana/latest/fundamentals/exemplars/). 193 | 194 | ## Optional metrics 195 | 196 | Optional metrics are pre-defined metrics that can be added to the default metrics. 197 | 198 | * `response_body_size`: the size of response bodies returned, in bytes 199 | * `request_body_size`: the size of request bodies received, in bytes 200 | 201 | **Example**: 202 | 203 | ```python 204 | from fastapi import FastAPI 205 | from starlette_exporter import PrometheusMiddleware, handle_metrics 206 | from starlette_exporter.optional_metrics import response_body_size, request_body_size 207 | 208 | app = FastAPI() 209 | app.add_middleware(PrometheusMiddleware, optional_metrics=[response_body_size, request_body_size]) 210 | ``` 211 | 212 | ## Custom Metrics 213 | 214 | starlette_exporter will export all the prometheus metrics from the process, so custom metrics can be created by using the prometheus_client API. 215 | 216 | **Example**: 217 | 218 | ```python 219 | from prometheus_client import Counter 220 | from starlette.responses import RedirectResponse 221 | 222 | REDIRECT_COUNT = Counter("redirect_total", "Count of redirects", ["redirected_from"]) 223 | 224 | async def some_view(request): 225 | REDIRECT_COUNT.labels("some_view").inc() 226 | return RedirectResponse(url="https://example.com", status_code=302) 227 | ``` 228 | 229 | The new metric will now be included in the the `/metrics` endpoint output: 230 | 231 | ``` 232 | ... 233 | redirect_total{redirected_from="some_view"} 2.0 234 | ... 235 | ``` 236 | 237 | ## Multiprocess mode (gunicorn deployments) 238 | 239 | Running starlette_exporter in a multiprocess deployment (e.g. with gunicorn) will need the `PROMETHEUS_MULTIPROC_DIR` env variable set, as well as extra gunicorn config. 240 | 241 | For more information, see the [Prometheus Python client documentation](https://github.com/prometheus/client_python#multiprocess-mode-eg-gunicorn). 242 | 243 | ## Developing 244 | 245 | This package supports Python 3.6+. 246 | 247 | ```sh 248 | git clone https://github.com/stephenhillier/starlette_exporter 249 | cd starlette_exporter 250 | pytest tests 251 | ``` 252 | 253 | ## License 254 | 255 | Code released under the [Apache License, Version 2.0](./LICENSE). 256 | 257 | ## Dependencies 258 | 259 | https://github.com/prometheus/client_python (>= 0.12) 260 | 261 | https://github.com/encode/starlette 262 | 263 | ## Credits 264 | 265 | Starlette - https://github.com/encode/starlette 266 | 267 | FastAPI - https://github.com/tiangolo/fastapi 268 | 269 | Flask exporter - https://github.com/rycus86/prometheus_flask_exporter 270 | 271 | Alternate Starlette exporter - https://github.com/perdy/starlette-prometheus 272 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | prometheus-client==0.15.0 2 | starlette==0.36.2 3 | requests==2.32.0 4 | aiofiles==22.1.0 5 | pytest==6.2.4 6 | httpx==0.23.3 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="starlette_exporter", 5 | version="0.23.0", 6 | author="Stephen Hillier", 7 | author_email="stephenhillier@gmail.com", 8 | packages=["starlette_exporter"], 9 | package_data={"starlette_exporter": ["py.typed"]}, 10 | license="Apache License 2.0", 11 | url="https://github.com/stephenhillier/starlette_exporter", 12 | description="Prometheus metrics exporter for Starlette applications.", 13 | long_description=open("README.md").read(), 14 | long_description_content_type="text/markdown", 15 | install_requires=["prometheus_client>=0.12", "starlette>=0.35"], 16 | python_requires=">=3.8", 17 | ) 18 | -------------------------------------------------------------------------------- /starlette_exporter/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'PrometheusMiddleware', 3 | 'from_header', 4 | 'from_response_header', 5 | 'handle_metrics', 6 | ] 7 | 8 | import os 9 | from prometheus_client import ( 10 | generate_latest, 11 | CONTENT_TYPE_LATEST, 12 | REGISTRY, 13 | multiprocess, 14 | CollectorRegistry, 15 | ) 16 | from prometheus_client.openmetrics.exposition import ( 17 | generate_latest as openmetrics_generate_latest, 18 | CONTENT_TYPE_LATEST as openmetrics_content_type_latest, 19 | ) 20 | from starlette.requests import Request 21 | from starlette.responses import Response 22 | 23 | from .middleware import PrometheusMiddleware 24 | from .labels import from_header, from_response_header 25 | 26 | 27 | def handle_metrics(request: Request) -> Response: 28 | """A handler to expose Prometheus metrics 29 | Example usage: 30 | 31 | ``` 32 | app.add_middleware(PrometheusMiddleware) 33 | app.add_route("/metrics", handle_metrics) 34 | ``` 35 | """ 36 | registry = REGISTRY 37 | if ( 38 | "prometheus_multiproc_dir" in os.environ 39 | or "PROMETHEUS_MULTIPROC_DIR" in os.environ 40 | ): 41 | registry = CollectorRegistry() 42 | multiprocess.MultiProcessCollector(registry) 43 | 44 | headers = {"Content-Type": CONTENT_TYPE_LATEST} 45 | return Response(generate_latest(registry), status_code=200, headers=headers) 46 | 47 | 48 | def handle_openmetrics(request: Request) -> Response: 49 | """A handler to expose Prometheus metrics in OpenMetrics format. 50 | This is required to expose metrics with exemplars. 51 | Example usage: 52 | 53 | ``` 54 | app.add_middleware(PrometheusMiddleware) 55 | app.add_route("/metrics", openmetrics_handler) 56 | ``` 57 | """ 58 | registry = REGISTRY 59 | if ( 60 | "prometheus_multiproc_dir" in os.environ 61 | or "PROMETHEUS_MULTIPROC_DIR" in os.environ 62 | ): 63 | registry = CollectorRegistry() 64 | multiprocess.MultiProcessCollector(registry) 65 | 66 | headers = {"Content-Type": openmetrics_content_type_latest} 67 | return Response( 68 | openmetrics_generate_latest(registry), 69 | status_code=200, 70 | headers=headers, 71 | ) 72 | -------------------------------------------------------------------------------- /starlette_exporter/labels.py: -------------------------------------------------------------------------------- 1 | """utilities for working with labels""" 2 | from typing import Any, Callable, Iterable, Optional, Dict 3 | 4 | from starlette.requests import Request 5 | 6 | 7 | class ResponseHeaderLabel: 8 | """ResponseHeaderLabel is a special class that allows populating a label 9 | value based on response headers. starlette_exporter will recognize that this 10 | should not be called until response headers are written.""" 11 | 12 | def __init__( 13 | self, key: str, allowed_values: Optional[Iterable] = None, default: str = "" 14 | ) -> None: 15 | self.key = key.lower() 16 | self.default = default 17 | self.allowed_values = allowed_values 18 | 19 | def __call__(self, headers: Dict) -> Any: 20 | v = headers.get(self.key, self.default) 21 | if self.allowed_values is not None and v not in self.allowed_values: 22 | return self.default 23 | return v 24 | 25 | 26 | def from_header(key: str, allowed_values: Optional[Iterable] = None) -> Callable: 27 | """returns a function that retrieves a header value from a request. 28 | The returned function can be passed to the `labels` argument of PrometheusMiddleware 29 | to label metrics using a header value. 30 | 31 | `key`: header key 32 | `allowed_values`: an iterable (e.g. list or tuple) containing an allowlist of values. Any 33 | header value not in allowed_values will result in an empty string being returned. Use 34 | this to constrain the potential label values. 35 | 36 | example: 37 | 38 | ``` 39 | PrometheusMiddleware( 40 | labels={ 41 | "host": from_header("X-User", allowed_values=("frank", "estelle")) 42 | } 43 | ) 44 | ``` 45 | """ 46 | 47 | def inner(r: Request): 48 | v = r.headers.get(key, "") 49 | 50 | # if allowed_values was supplied, return a blank string if 51 | # the value of the header does match any of the values. 52 | if allowed_values is not None and v not in allowed_values: 53 | return "" 54 | 55 | return v 56 | 57 | return inner 58 | 59 | 60 | def from_response_header( 61 | key: str, allowed_values: Optional[Iterable] = None, default: str = "" 62 | ): 63 | """returns a callable class that retrieves a header value from response headers. 64 | starlette_exporter will automatically populate this label value when response headers 65 | are written.""" 66 | return ResponseHeaderLabel(key, allowed_values, default) 67 | -------------------------------------------------------------------------------- /starlette_exporter/middleware.py: -------------------------------------------------------------------------------- 1 | """ Middleware for exporting Prometheus metrics using Starlette """ 2 | import inspect 3 | import logging 4 | import re 5 | import time 6 | import warnings 7 | from collections import OrderedDict 8 | from contextlib import suppress 9 | from inspect import iscoroutine 10 | from typing import ( 11 | Any, 12 | Callable, 13 | ClassVar, 14 | Dict, 15 | List, 16 | Mapping, 17 | Optional, 18 | Sequence, 19 | Union, 20 | ) 21 | 22 | from prometheus_client import Counter, Gauge, Histogram 23 | from prometheus_client.metrics import MetricWrapperBase 24 | from starlette.requests import Request 25 | from starlette.routing import BaseRoute, Match 26 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 27 | 28 | from starlette_exporter.labels import ResponseHeaderLabel 29 | 30 | from . import optional_metrics 31 | 32 | logger = logging.getLogger("starlette_exporter") 33 | 34 | 35 | def get_matching_route_path( 36 | scope: Dict[Any, Any], 37 | routes: List[BaseRoute], 38 | route_name: Optional[str] = None, 39 | ) -> Optional[str]: 40 | """ 41 | Find a matching route and return its original path string 42 | 43 | Will attempt to enter mounted routes and subrouters. 44 | 45 | Credit to https://github.com/elastic/apm-agent-python 46 | """ 47 | 48 | for route in routes: 49 | match, child_scope = route.matches(scope) 50 | if match == Match.FULL: 51 | # set route name 52 | route_name = getattr(route, "path", None) 53 | if route_name is None: 54 | return None 55 | 56 | # for routes of type `BaseRoute`, the base route name may not 57 | # be the complete path (it may represent the path to the 58 | # mounted router). If this is a mounted route, descend into it to 59 | # get the complete path. 60 | if isinstance(route, BaseRoute) and getattr(route, "routes", None): 61 | child_scope = {**scope, **child_scope} 62 | child_route_name = get_matching_route_path( 63 | child_scope, 64 | getattr(route, "routes"), 65 | route_name, 66 | ) 67 | if child_route_name is None: 68 | route_name = None 69 | else: 70 | route_name += child_route_name 71 | return route_name 72 | elif match == Match.PARTIAL and route_name is None: 73 | route_name = getattr(route, "path", None) 74 | 75 | return None 76 | 77 | 78 | class PrometheusMiddleware: 79 | """Middleware that collects Prometheus metrics for each request. 80 | Use in conjuction with the Prometheus exporter endpoint handler. 81 | """ 82 | 83 | _metrics: ClassVar[Dict[str, MetricWrapperBase]] = {} 84 | 85 | def __init__( 86 | self, 87 | app: ASGIApp, 88 | group_paths: bool = True, 89 | app_name: str = "starlette", 90 | prefix: str = "starlette", 91 | buckets: Optional[Sequence[Union[float, str]]] = None, 92 | filter_unhandled_paths: bool = True, 93 | skip_paths: Optional[List[str]] = None, 94 | skip_methods: Optional[List[str]] = None, 95 | optional_metrics: Optional[List[str]] = None, 96 | always_use_int_status: bool = False, 97 | labels: Optional[Mapping[str, Union[str, Callable]]] = None, 98 | exemplars: Optional[Callable] = None, 99 | group_unhandled_paths: bool = False, 100 | ): 101 | self.app = app 102 | self.app_name = app_name 103 | self.prefix = prefix 104 | self.group_paths = group_paths 105 | 106 | if group_unhandled_paths and filter_unhandled_paths: 107 | filter_unhandled_paths = False 108 | warnings.warn( 109 | "filter_unhandled_paths was set to True but has been changed to False " 110 | "because group_unhandled_paths is True and these settings are mutually exclusive", 111 | UserWarning, 112 | ) 113 | 114 | self.group_unhandled_paths = group_unhandled_paths 115 | self.filter_unhandled_paths = filter_unhandled_paths 116 | 117 | self.kwargs = {} 118 | if buckets is not None: 119 | self.kwargs["buckets"] = buckets 120 | self.skip_paths: List[re.Pattern] = [] 121 | if skip_paths is not None: 122 | self.skip_paths = [re.compile(path) for path in skip_paths] 123 | self.skip_methods = [] 124 | if skip_methods is not None: 125 | self.skip_methods = skip_methods 126 | self.optional_metrics_list = [] 127 | if optional_metrics is not None: 128 | self.optional_metrics_list = optional_metrics 129 | self.always_use_int_status = always_use_int_status 130 | 131 | self.exemplars = exemplars 132 | self._exemplars_req_kw = "" 133 | 134 | if self.exemplars: 135 | # if the exemplars func has an argument annotated as Request, note its name. 136 | # it will be used to inject the request when the func is called 137 | exemplar_sig = inspect.signature(self.exemplars) 138 | for p in exemplar_sig.parameters.values(): 139 | if p.annotation is Request: 140 | self._exemplars_req_kw = p.name 141 | break 142 | else: 143 | # if there's no parameter with a Request type annotation but there is a 144 | # parameter with name "request", it will be chosen for injection 145 | if "request" in exemplar_sig.parameters: 146 | self._exemplars_req_kw = "request" 147 | 148 | 149 | # split labels into request and response labels. 150 | # response labels will be evaluated while the response is 151 | # written. 152 | self.request_labels = OrderedDict({}) 153 | self.response_labels: OrderedDict[str, ResponseHeaderLabel] = OrderedDict({}) 154 | 155 | if labels is not None: 156 | for k, v in labels.items(): 157 | if isinstance(v, ResponseHeaderLabel): 158 | self.response_labels[k] = v 159 | else: 160 | self.request_labels[k] = v 161 | 162 | # Default metrics 163 | # Starlette initialises middleware multiple times, so store metrics on the class 164 | 165 | @property 166 | def request_count(self): 167 | metric_name = f"{self.prefix}_requests_total" 168 | if metric_name not in PrometheusMiddleware._metrics: 169 | PrometheusMiddleware._metrics[metric_name] = Counter( 170 | metric_name, 171 | "Total HTTP requests", 172 | ( 173 | "method", 174 | "path", 175 | "status_code", 176 | "app_name", 177 | *self.request_labels.keys(), 178 | *self.response_labels.keys(), 179 | ), 180 | ) 181 | return PrometheusMiddleware._metrics[metric_name] 182 | 183 | @property 184 | def response_body_size_count(self): 185 | """ 186 | Optional metric for tracking the size of response bodies. 187 | If using gzip middleware, you should test that the starlette_exporter middleware computes 188 | the proper response size value. Please post any feedback on this metric as an issue 189 | at https://github.com/stephenhillier/starlette_exporter. 190 | 191 | """ 192 | if ( 193 | self.optional_metrics_list is not None 194 | and optional_metrics.response_body_size in self.optional_metrics_list 195 | ): 196 | metric_name = f"{self.prefix}_response_body_bytes_total" 197 | if metric_name not in PrometheusMiddleware._metrics: 198 | PrometheusMiddleware._metrics[metric_name] = Counter( 199 | metric_name, 200 | "Total HTTP response body bytes", 201 | ( 202 | "method", 203 | "path", 204 | "status_code", 205 | "app_name", 206 | *self.request_labels.keys(), 207 | *self.response_labels.keys(), 208 | ), 209 | ) 210 | return PrometheusMiddleware._metrics[metric_name] 211 | else: 212 | pass 213 | 214 | @property 215 | def request_body_size_count(self): 216 | """ 217 | Optional metric tracking the received content-lengths of request bodies 218 | """ 219 | if ( 220 | self.optional_metrics_list is not None 221 | and optional_metrics.request_body_size in self.optional_metrics_list 222 | ): 223 | metric_name = f"{self.prefix}_request_body_bytes_total" 224 | if metric_name not in PrometheusMiddleware._metrics: 225 | PrometheusMiddleware._metrics[metric_name] = Counter( 226 | metric_name, 227 | "Total HTTP request body bytes", 228 | ( 229 | "method", 230 | "path", 231 | "status_code", 232 | "app_name", 233 | *self.request_labels.keys(), 234 | *self.response_labels.keys(), 235 | ), 236 | ) 237 | return PrometheusMiddleware._metrics[metric_name] 238 | else: 239 | pass 240 | 241 | @property 242 | def request_time(self): 243 | metric_name = f"{self.prefix}_request_duration_seconds" 244 | if metric_name not in PrometheusMiddleware._metrics: 245 | PrometheusMiddleware._metrics[metric_name] = Histogram( 246 | metric_name, 247 | "HTTP request duration, in seconds", 248 | ( 249 | "method", 250 | "path", 251 | "status_code", 252 | "app_name", 253 | *self.request_labels.keys(), 254 | *self.response_labels.keys(), 255 | ), 256 | **self.kwargs, 257 | ) 258 | return PrometheusMiddleware._metrics[metric_name] 259 | 260 | @property 261 | def requests_in_progress(self): 262 | metric_name = f"{self.prefix}_requests_in_progress" 263 | if metric_name not in PrometheusMiddleware._metrics: 264 | PrometheusMiddleware._metrics[metric_name] = Gauge( 265 | metric_name, 266 | "Total HTTP requests currently in progress", 267 | ("method", "app_name", *self.request_labels.keys()), 268 | multiprocess_mode="livesum", 269 | ) 270 | return PrometheusMiddleware._metrics[metric_name] 271 | 272 | async def _request_label_values(self, request: Request) -> List[str]: 273 | values: List[str] = [] 274 | 275 | for k, v in self.request_labels.items(): 276 | if callable(v): 277 | parsed_value = "" 278 | # if provided a callable, try to use it on the request. 279 | try: 280 | result = v(request) 281 | if iscoroutine(result): 282 | result = await result 283 | except Exception: 284 | logger.warn(f"label function for {k} failed", exc_info=True) 285 | else: 286 | parsed_value = str(result) 287 | values.append(parsed_value) 288 | continue 289 | 290 | values.append(v) 291 | 292 | return values 293 | 294 | def _response_label_values(self, message: Message) -> List[str]: 295 | values: List[str] = [] 296 | 297 | # bail if no response labels were defined by the user 298 | if not self.response_labels: 299 | return values 300 | 301 | # create a dict of headers to make it easy to find keys 302 | headers = { 303 | k.decode("utf-8").lower(): v.decode("utf-8") 304 | for (k, v) in message.get("headers", ()) 305 | } 306 | 307 | for k, v in self.response_labels.items(): 308 | # currently only ResponseHeaderLabel supported 309 | if isinstance(v, ResponseHeaderLabel): 310 | parsed_value = "" 311 | try: 312 | result = v(headers) 313 | except Exception: 314 | logger.warn(f"label function for {k} failed", exc_info=True) 315 | else: 316 | parsed_value = str(result) 317 | values.append(parsed_value) 318 | 319 | 320 | return values 321 | 322 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 323 | if scope["type"] not in ["http"]: 324 | await self.app(scope, receive, send) 325 | return 326 | 327 | request = Request(scope, receive) 328 | 329 | method = request.method 330 | path = request.url.path 331 | base_path = request.base_url.path.rstrip("/") 332 | 333 | if base_path and path.startswith(base_path): 334 | path = path[len(base_path) :] 335 | 336 | if any(pattern.fullmatch(path) for pattern in self.skip_paths) or method in self.skip_methods: 337 | await self.app(scope, receive, send) 338 | return 339 | 340 | begin = time.perf_counter() 341 | end = None 342 | 343 | request_labels = await self._request_label_values(request) 344 | 345 | # Increment requests_in_progress gauge when request comes in 346 | self.requests_in_progress.labels(method, self.app_name, *request_labels).inc() 347 | 348 | status_code = None 349 | 350 | # custom response label values, to be populated when response is written. 351 | response_labels = [] 352 | 353 | # optional request and response body size metrics 354 | response_body_size: int = 0 355 | 356 | request_body_size: int = 0 357 | if ( 358 | self.optional_metrics_list is not None 359 | and optional_metrics.request_body_size in self.optional_metrics_list 360 | ): 361 | if request.headers.get("content-length"): 362 | request_body_size = int(request.headers["content-length"]) 363 | 364 | async def wrapped_send(message: Message) -> None: 365 | if message["type"] == "http.response.start": 366 | nonlocal status_code 367 | status_code = message["status"] 368 | 369 | nonlocal response_labels 370 | response_labels = self._response_label_values(message) 371 | 372 | if self.always_use_int_status: 373 | try: 374 | status_code = int(message["status"]) 375 | except ValueError: 376 | logger.warning( 377 | f"always_use_int_status flag selected but failed to convert status_code to int for value: {status_code}" 378 | ) 379 | 380 | # find response body size for optional metric 381 | if ( 382 | self.optional_metrics_list is not None 383 | and optional_metrics.response_body_size 384 | in self.optional_metrics_list 385 | ): 386 | nonlocal response_body_size 387 | for message_content_length in message["headers"]: 388 | if ( 389 | message_content_length[0].decode("utf-8") 390 | == "content-length" 391 | ): 392 | response_body_size += int( 393 | message_content_length[1].decode("utf-8") 394 | ) 395 | 396 | if message["type"] == "http.response.body": 397 | nonlocal end 398 | end = time.perf_counter() 399 | 400 | await send(message) 401 | 402 | exception: Optional[Exception] = None 403 | original_scope = scope.copy() 404 | try: 405 | await self.app(scope, receive, wrapped_send) 406 | except Exception as e: 407 | status_code = 500 408 | 409 | # during an unhandled exception, populate response labels with empty strings. 410 | response_labels = self._response_label_values({}) 411 | 412 | exception = e 413 | finally: 414 | # Decrement 'requests_in_progress' gauge after response sent 415 | self.requests_in_progress.labels( 416 | method, self.app_name, *request_labels 417 | ).dec() 418 | 419 | if status_code is None: 420 | if await request.is_disconnected(): 421 | # In case no response was returned and the client is disconnected, 499 is reported as status code. 422 | status_code = 499 423 | else: 424 | status_code = 500 425 | 426 | if self.filter_unhandled_paths or self.group_paths or self.group_unhandled_paths: 427 | grouped_path: Optional[str] = None 428 | 429 | endpoint = scope.get("endpoint", None) 430 | router = scope.get("router", None) 431 | if endpoint and router: 432 | with suppress(Exception): 433 | grouped_path = get_matching_route_path(original_scope, router.routes) 434 | 435 | # filter_unhandled_paths removes any requests without mapped endpoint from the metrics. 436 | if self.filter_unhandled_paths and grouped_path is None: 437 | if exception: 438 | raise exception 439 | return 440 | 441 | # group_unhandled_paths works similar to filter_unhandled_paths, but instead of 442 | # removing the request from the metrics, it groups it under a single path. 443 | if self.group_unhandled_paths and grouped_path is None: 444 | path = "__unknown__" 445 | 446 | # group_paths enables returning the original router path (with url param names) 447 | # for example, when using this option, requests to /api/product/1 and /api/product/3 448 | # will both be grouped under /api/product/{product_id}. See the README for more info. 449 | if self.group_paths and grouped_path is not None: 450 | path = grouped_path 451 | 452 | labels = [ 453 | method, 454 | path, 455 | status_code, 456 | self.app_name, 457 | *request_labels, 458 | *response_labels, 459 | ] 460 | 461 | # optional extra arguments to be passed as kwargs to observations 462 | # note: only used for histogram observations and counters to support exemplars 463 | extra = {} 464 | if self.exemplars: 465 | exemplar_kwargs = {} 466 | if self._exemplars_req_kw: 467 | exemplar_kwargs[self._exemplars_req_kw] = request 468 | extra["exemplar"] = self.exemplars(**exemplar_kwargs) 469 | 470 | # optional response body size metric 471 | if ( 472 | self.optional_metrics_list is not None 473 | and optional_metrics.response_body_size in self.optional_metrics_list 474 | and self.response_body_size_count is not None 475 | ): 476 | self.response_body_size_count.labels(*labels).inc( 477 | response_body_size, **extra 478 | ) 479 | 480 | # optional request body size metric 481 | if ( 482 | self.optional_metrics_list is not None 483 | and optional_metrics.request_body_size in self.optional_metrics_list 484 | and self.request_body_size_count is not None 485 | ): 486 | self.request_body_size_count.labels(*labels).inc( 487 | request_body_size, **extra 488 | ) 489 | 490 | # if we were not able to set end when the response body was written, 491 | # set it now. 492 | if end is None: 493 | end = time.perf_counter() 494 | 495 | self.request_count.labels(*labels).inc(**extra) 496 | self.request_time.labels(*labels).observe(end - begin, **extra) 497 | 498 | if exception: 499 | raise exception 500 | 501 | -------------------------------------------------------------------------------- /starlette_exporter/optional_metrics.py: -------------------------------------------------------------------------------- 1 | """ optional_metrics.py: Optional additional metrics 2 | 3 | Additional metrics are defined here as string constants. The metric functionality 4 | is located within the PrometheusMiddleware class and is enabled by passing one of these 5 | constants to the `optional_metrics` constructor param. 6 | 7 | Example: 8 | 9 | ``` 10 | from starlette_exporter import PrometheusMiddleware 11 | from starlette_exporter.optional_metrics import response_body_size 12 | 13 | app.add_middleware(PrometheusMiddleware, optional_metrics=[response_body_size, request_body_size]) 14 | ``` 15 | """ 16 | 17 | 18 | response_body_size = "response_body_size" 19 | request_body_size = "request_body_size" 20 | 21 | all_metrics = [ 22 | response_body_size, 23 | request_body_size, 24 | ] 25 | -------------------------------------------------------------------------------- /starlette_exporter/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephenhillier/starlette_exporter/05b9e18ccbd6f2925a229805d21b87a4000a740f/starlette_exporter/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephenhillier/starlette_exporter/05b9e18ccbd6f2925a229805d21b87a4000a740f/tests/__init__.py -------------------------------------------------------------------------------- /tests/static/test.txt: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import time 2 | from http import HTTPStatus 3 | 4 | import pytest 5 | from prometheus_client import REGISTRY 6 | from starlette.applications import Starlette 7 | from starlette.background import BackgroundTask 8 | from starlette.exceptions import HTTPException 9 | from starlette.requests import Request 10 | from starlette.responses import JSONResponse, Response 11 | from starlette.routing import Mount, Route 12 | from starlette.staticfiles import StaticFiles 13 | from starlette.testclient import TestClient 14 | 15 | import starlette_exporter 16 | from starlette_exporter import ( 17 | PrometheusMiddleware, 18 | from_header, 19 | from_response_header, 20 | handle_metrics, 21 | handle_openmetrics, 22 | ) 23 | from starlette_exporter.optional_metrics import request_body_size, response_body_size 24 | 25 | 26 | @pytest.fixture 27 | def testapp(): 28 | """create a test app with various endpoints for the test scenarios""" 29 | 30 | # unregister all the collectors before we start 31 | collectors = list(REGISTRY._collector_to_names.keys()) 32 | for collector in collectors: 33 | REGISTRY.unregister(collector) 34 | 35 | PrometheusMiddleware._metrics = {} 36 | 37 | def _testapp(**middleware_options): 38 | app = Starlette() 39 | app.add_middleware( 40 | starlette_exporter.PrometheusMiddleware, **middleware_options 41 | ) 42 | app.add_route("/metrics", handle_metrics) 43 | app.add_route("/openmetrics", handle_openmetrics) 44 | 45 | def normal_response(_): 46 | return JSONResponse({"message": "Hello World"}, headers={"foo": "baz"}) 47 | 48 | app.add_route("/200", normal_response, methods=["GET", "POST", "OPTIONS"]) 49 | app.add_route( 50 | "/200/{test_param}", normal_response, methods=["GET", "POST", "OPTIONS"] 51 | ) 52 | 53 | def httpstatus_response(request): 54 | """ 55 | Returns a JSON Response using status_code = HTTPStatus.OK if the param is set to OK 56 | otherewise it returns a JSON response with status_code = 200 57 | """ 58 | if request.path_params["test_param"] == "OK": 59 | return Response(status_code=HTTPStatus.OK) 60 | else: 61 | return Response(status_code=200) 62 | 63 | app.add_route( 64 | "/200_or_httpstatus/{test_param}", 65 | httpstatus_response, 66 | methods=["GET", "OPTIONS"], 67 | ) 68 | 69 | async def error(request): 70 | raise HTTPException(status_code=500, detail="this is a test error", headers={"foo":"baz"}) 71 | 72 | app.add_route("/500", error) 73 | app.add_route("/500/{test_param}", error) 74 | 75 | async def unhandled(request): 76 | test_dict = {"yup": 123} 77 | return JSONResponse({"message": test_dict["value_error"]}) 78 | 79 | app.add_route("/unhandled", unhandled) 80 | app.add_route("/unhandled/{test_param}", unhandled) 81 | 82 | async def background(request): 83 | def backgroundtask(): 84 | time.sleep(0.1) 85 | 86 | task = BackgroundTask(backgroundtask) 87 | return JSONResponse({"message": "task started"}, background=task) 88 | 89 | app.add_route("/background", background) 90 | 91 | def healthcheck(request): 92 | return JSONResponse({"message": "Healthcheck route"}) 93 | 94 | app.add_route("/health", healthcheck) 95 | 96 | # testing routes added using Mount 97 | async def test_mounted_function(request): 98 | return JSONResponse({"message": "Hello World"}) 99 | 100 | async def test_mounted_function_param(request): 101 | return JSONResponse({"message": request.path_params.get("item")}) 102 | 103 | mounted_routes = Mount( 104 | "/", 105 | routes=[ 106 | Route("/test/{item}", test_mounted_function_param, methods=["GET"]), 107 | Route("/test", test_mounted_function), 108 | ], 109 | ) 110 | 111 | app.mount("/mounted", mounted_routes) 112 | app.mount("/static", app=StaticFiles(directory="tests/static"), name="static") 113 | return app 114 | 115 | return _testapp 116 | 117 | 118 | class TestMiddleware: 119 | @pytest.fixture 120 | def client(self, testapp): 121 | return TestClient(testapp()) 122 | 123 | def test_200(self, client): 124 | """test that requests appear in the counter""" 125 | client.get("/200") 126 | metrics = client.get("/metrics").content.decode() 127 | assert ( 128 | """starlette_requests_total{app_name="starlette",method="GET",path="/200",status_code="200"} 1.0""" 129 | in metrics 130 | ) 131 | 132 | def test_500(self, client): 133 | """test that a handled exception (HTTPException) gets logged in the requests counter""" 134 | 135 | client.get("/500") 136 | metrics = client.get("/metrics").content.decode() 137 | 138 | assert ( 139 | """starlette_requests_total{app_name="starlette",method="GET",path="/500",status_code="500"} 1.0""" 140 | in metrics 141 | ) 142 | 143 | def test_404_filter_unhandled_paths_off(self, testapp): 144 | """test that an unknown path is captured in metrics if filter_unhandled_paths=False""" 145 | client = TestClient(testapp(filter_unhandled_paths=False)) 146 | client.get("/404") 147 | metrics = client.get("/metrics").content.decode() 148 | 149 | assert ( 150 | """starlette_requests_total{app_name="starlette",method="GET",path="/404",status_code="404"} 1.0""" 151 | in metrics 152 | ) 153 | 154 | def test_404_filter(self, client): 155 | """test that a unknown path can be excluded from metrics""" 156 | 157 | try: 158 | client.get("/404") 159 | except: 160 | pass 161 | metrics = client.get("/metrics").content.decode() 162 | 163 | assert "/404" not in metrics 164 | 165 | def test_404_group_unhandled_paths_on(self, testapp): 166 | """test that an unknown path is captured in metrics if group_unhandled_paths=True""" 167 | 168 | client = TestClient(testapp(group_unhandled_paths=True, filter_unhandled_paths=False)) 169 | client.get("/404") 170 | 171 | metrics = client.get("/metrics").content.decode() 172 | 173 | assert ( 174 | """starlette_requests_total{app_name="starlette",method="GET",path="__unknown__",status_code="404"} 1.0""" 175 | in metrics 176 | ) 177 | 178 | def test_unhandled(self, client): 179 | """test that an unhandled exception still gets logged in the requests counter""" 180 | 181 | with pytest.raises(KeyError, match="value_error"): 182 | client.get("/unhandled") 183 | 184 | metrics = client.get("/metrics").content.decode() 185 | 186 | assert ( 187 | """starlette_requests_total{app_name="starlette",method="GET",path="/unhandled",status_code="500"} 1.0""" 188 | in metrics 189 | ) 190 | 191 | def test_ungrouped_paths(self, testapp): 192 | """test that an endpoints parameters with group_paths=False are added to metrics""" 193 | 194 | client = TestClient(testapp(group_paths=False)) 195 | 196 | client.get("/200/111") 197 | client.get("/500/1111") 198 | client.get("/404/11111") 199 | 200 | with pytest.raises(KeyError, match="value_error"): 201 | client.get("/unhandled/123") 202 | 203 | metrics = client.get("/metrics").content.decode() 204 | 205 | assert ( 206 | """starlette_requests_total{app_name="starlette",method="GET",path="/200/111",status_code="200"} 1.0""" 207 | in metrics 208 | ) 209 | assert ( 210 | """starlette_requests_total{app_name="starlette",method="GET",path="/500/1111",status_code="500"} 1.0""" 211 | in metrics 212 | ) 213 | assert "/404" not in metrics 214 | 215 | assert ( 216 | """starlette_requests_total{app_name="starlette",method="GET",path="/unhandled/123",status_code="500"} 1.0""" 217 | in metrics 218 | ) 219 | 220 | def test_histogram(self, client): 221 | """test that histogram buckets appear after making requests""" 222 | 223 | client.get("/200") 224 | client.get("/500") 225 | try: 226 | client.get("/unhandled") 227 | except: 228 | pass 229 | 230 | metrics = client.get("/metrics").content.decode() 231 | 232 | assert ( 233 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="0.005",method="GET",path="/200",status_code="200"}""" 234 | in metrics 235 | ) 236 | assert ( 237 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="0.005",method="GET",path="/500",status_code="500"}""" 238 | in metrics 239 | ) 240 | assert ( 241 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="0.005",method="GET",path="/unhandled",status_code="500"}""" 242 | in metrics 243 | ) 244 | 245 | def test_histogram_custom_buckets(self, testapp): 246 | """test that custom histogram buckets appear after making requests""" 247 | 248 | buckets = (10, 20, 30, 40, 50) 249 | client = TestClient(testapp(buckets=buckets)) 250 | client.get("/200") 251 | client.get("/500") 252 | try: 253 | client.get("/unhandled") 254 | except: 255 | pass 256 | 257 | metrics = client.get("/metrics").content.decode() 258 | 259 | assert ( 260 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="50.0",method="GET",path="/200",status_code="200"}""" 261 | in metrics 262 | ) 263 | assert ( 264 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="50.0",method="GET",path="/500",status_code="500"}""" 265 | in metrics 266 | ) 267 | assert ( 268 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="50.0",method="GET",path="/unhandled",status_code="500"}""" 269 | in metrics 270 | ) 271 | 272 | def test_app_name(self, testapp): 273 | """test that app_name label is populated correctly""" 274 | client = TestClient(testapp(app_name="testing")) 275 | 276 | client.get("/200") 277 | metrics = client.get("/metrics").content.decode() 278 | assert ( 279 | """starlette_requests_total{app_name="testing",method="GET",path="/200",status_code="200"} 1.0""" 280 | in metrics 281 | ) 282 | 283 | def test_mounted_path(self, testapp): 284 | """test that mounted paths appear even when filter_unhandled_paths is True""" 285 | client = TestClient(testapp(filter_unhandled_paths=True)) 286 | client.get("/mounted/test") 287 | metrics = client.get("/metrics").content.decode() 288 | assert ( 289 | """starlette_requests_total{app_name="starlette",method="GET",path="/mounted/test",status_code="200"} 1.0""" 290 | in metrics 291 | ) 292 | 293 | def test_mounted_path_with_param(self, testapp): 294 | """test that mounted paths appear even when filter_unhandled_paths is True 295 | this test uses a path param that needs to be found within the mounted route. 296 | """ 297 | client = TestClient(testapp(filter_unhandled_paths=True, group_paths=True)) 298 | client.get("/mounted/test/123") 299 | metrics = client.get("/metrics").content.decode() 300 | assert ( 301 | """starlette_requests_total{app_name="starlette",method="GET",path="/mounted/test/{item}",status_code="200"} 1.0""" 302 | in metrics 303 | ) 304 | 305 | def test_mounted_path_404(self, testapp): 306 | """test an unhandled path that will be partially matched at the mounted base path, if 307 | filter_unhandled_paths=False""" 308 | client = TestClient(testapp(filter_unhandled_paths=False)) 309 | client.get("/mounted/404") 310 | metrics = client.get("/metrics").content.decode() 311 | 312 | assert ( 313 | """starlette_requests_total{app_name="starlette",method="GET",path="/mounted/404",status_code="404"} 1.0""" 314 | in metrics 315 | ) 316 | 317 | def test_mounted_path_404_filter(self, testapp): 318 | """test an unhandled path from mounted base path can be excluded from metrics""" 319 | client = TestClient(testapp()) 320 | client.get("/mounted/404") 321 | metrics = client.get("/metrics").content.decode() 322 | 323 | assert "/mounted" not in metrics 324 | 325 | def test_staticfiles_path(self, testapp): 326 | """test a static file path""" 327 | client = TestClient(testapp(filter_unhandled_paths=False, group_paths=False)) 328 | client.get("/static/test.txt") 329 | metrics = client.get("/metrics").content.decode() 330 | assert """path="/static/test.txt""" in metrics 331 | 332 | def test_prefix(self, testapp): 333 | """test that metric prefixes work""" 334 | client = TestClient(testapp(prefix="myapp")) 335 | 336 | client.get("/200") 337 | metrics = client.get("/metrics").content.decode() 338 | assert ( 339 | """myapp_requests_total{app_name="starlette",method="GET",path="/200",status_code="200"} 1.0""" 340 | in metrics 341 | ) 342 | 343 | def test_multi_init(self, testapp): 344 | """test that the middleware is happy being initialised multiple times""" 345 | # newer starlette versions do this 346 | # prometheus doesn't like the same metric being registered twice. 347 | PrometheusMiddleware(None) 348 | PrometheusMiddleware(None) 349 | 350 | def test_multi_prefix(self, testapp): 351 | """test that two collecting apps don't clash""" 352 | client1 = TestClient(testapp(prefix="app1")) 353 | client2 = TestClient(testapp(prefix="app2")) 354 | 355 | client1.get("/200") 356 | client2.get("/200") 357 | 358 | # both will return the same metrics though 359 | metrics1 = client1.get("/metrics").content.decode() 360 | metrics2 = client2.get("/metrics").content.decode() 361 | 362 | assert ( 363 | """app1_requests_total{app_name="starlette",method="GET",path="/200",status_code="200"} 1.0""" 364 | in metrics1 365 | ) 366 | assert ( 367 | """app2_requests_total{app_name="starlette",method="GET",path="/200",status_code="200"} 1.0""" 368 | in metrics1 369 | ) 370 | assert ( 371 | """app1_requests_total{app_name="starlette",method="GET",path="/200",status_code="200"} 1.0""" 372 | in metrics2 373 | ) 374 | assert ( 375 | """app2_requests_total{app_name="starlette",method="GET",path="/200",status_code="200"} 1.0""" 376 | in metrics2 377 | ) 378 | 379 | def test_requests_in_progress(self, client): 380 | """test that the requests_in_progress metric (a gauge) is incremented after one request. 381 | This test is fairly trivial and doesn't cover decrementing at the end of the request. 382 | TODO: create a second asyncronous request and check that the counter is incremented 383 | multiple times (and decremented back to zero when all requests done). 384 | """ 385 | 386 | metrics = client.get("/metrics").content.decode() 387 | assert ( 388 | """starlette_requests_in_progress{app_name="starlette",method="GET"} 1.0""" 389 | in metrics 390 | ) 391 | 392 | # try a second time as an alternate way to check that the requests_in_progress metric 393 | # was decremented at the end of the first request. This test could be improved, but 394 | # at the very least, it checks that the gauge wasn't incremented multiple times without 395 | # also being decremented. 396 | metrics = client.get("/metrics").content.decode() 397 | assert ( 398 | """starlette_requests_in_progress{app_name="starlette",method="GET"} 1.0""" 399 | in metrics 400 | ) 401 | 402 | def test_skip_paths(self, testapp): 403 | """test that requests doesn't appear in the counter""" 404 | client = TestClient(testapp(skip_paths=["/health"])) 405 | client.get("/health") 406 | metrics = client.get("/metrics").content.decode() 407 | assert """path="/health""" not in metrics 408 | 409 | def test_skip_paths__re(self, testapp): 410 | """test skip_paths using regular expression""" 411 | client = TestClient(testapp(skip_paths=[r"/h.*"])) 412 | client.get("/health") 413 | metrics = client.get("/metrics").content.decode() 414 | assert """path="/health""" not in metrics 415 | 416 | def test_skip_paths__re_partial(self, testapp): 417 | """test skip_paths using regular expression""" 418 | client = TestClient(testapp(skip_paths=[r"/h"])) 419 | client.get("/health") 420 | metrics = client.get("/metrics").content.decode() 421 | assert """path="/health""" in metrics 422 | 423 | def test_skip_methods(self, testapp): 424 | """test that requests doesn't appear in the counter""" 425 | client = TestClient(testapp(skip_methods=["POST"])) 426 | client.post("/200") 427 | metrics = client.get("/metrics").content.decode() 428 | assert """path="/200""" not in metrics 429 | 430 | 431 | class TestMiddlewareGroupedPaths: 432 | """tests for group_paths option (using named parameters to group endpoint metrics with path params together)""" 433 | 434 | @pytest.fixture 435 | def client(self, testapp): 436 | return TestClient(testapp(group_paths=True)) 437 | 438 | def test_200(self, client): 439 | """test that metrics are grouped by endpoint""" 440 | client.get("/200/111") 441 | metrics = client.get("/metrics").content.decode() 442 | 443 | assert ( 444 | """starlette_requests_total{app_name="starlette",method="GET",path="/200/{test_param}",status_code="200"} 1.0""" 445 | in metrics 446 | ) 447 | 448 | def test_200_options(self, client): 449 | """test that metrics are grouped by endpoint""" 450 | client.options("/200/111") 451 | metrics = client.get("/metrics").content.decode() 452 | 453 | assert ( 454 | """starlette_requests_total{app_name="starlette",method="OPTIONS",path="/200/{test_param}",status_code="200"} 1.0""" 455 | in metrics 456 | ) 457 | 458 | assert """method="OPTIONS",path="/200/111""" not in metrics 459 | 460 | def test_500(self, client): 461 | """test that a handled exception (HTTPException) gets logged in the requests counter""" 462 | 463 | client.get("/500/1111") 464 | metrics = client.get("/metrics").content.decode() 465 | 466 | assert ( 467 | """starlette_requests_total{app_name="starlette",method="GET",path="/500/{test_param}",status_code="500"} 1.0""" 468 | in metrics 469 | ) 470 | 471 | def test_404(self, client): 472 | """test that a 404 is handled properly, even though the path won't be grouped""" 473 | try: 474 | client.get("/404/11111") 475 | except: 476 | pass 477 | metrics = client.get("/metrics").content.decode() 478 | 479 | assert ( 480 | "/404" not in metrics 481 | ) 482 | 483 | def test_unhandled(self, client): 484 | """test that an unhandled exception still gets logged in the requests counter (grouped paths)""" 485 | 486 | with pytest.raises(KeyError, match="value_error"): 487 | client.get("/unhandled/123") 488 | 489 | metrics = client.get("/metrics").content.decode() 490 | 491 | assert ( 492 | """starlette_requests_total{app_name="starlette",method="GET",path="/unhandled/{test_param}",status_code="500"} 1.0""" 493 | in metrics 494 | ) 495 | 496 | def test_mounted_path_404_unfiltered(self, testapp): 497 | """test an unhandled path that will be partially matched at the mounted base path (grouped paths)""" 498 | client = TestClient(testapp(group_paths=True, filter_unhandled_paths=False)) 499 | client.get("/mounted/404") 500 | metrics = client.get("/metrics").content.decode() 501 | 502 | assert ( 503 | """starlette_requests_total{app_name="starlette",method="GET",path="/mounted/404",status_code="404"} 1.0""" 504 | in metrics 505 | ) 506 | 507 | def test_mounted_path_404_filter(self, testapp): 508 | """test an unhandled path from mounted base path can be excluded from metrics (grouped paths)""" 509 | client = TestClient(testapp(group_paths=True, filter_unhandled_paths=True)) 510 | client.get("/mounted/404") 511 | metrics = client.get("/metrics").content.decode() 512 | 513 | assert "/mounted" not in metrics 514 | 515 | def test_staticfiles_path(self, testapp): 516 | """test a static file path, with group_paths=True""" 517 | client = TestClient(testapp()) 518 | client.get("/static/test.txt") 519 | metrics = client.get("/metrics").content.decode() 520 | 521 | assert ( 522 | """starlette_requests_total{app_name="starlette",method="GET",path="/static",status_code="200"} 1.0""" 523 | in metrics 524 | ) 525 | 526 | def test_histogram(self, client): 527 | """test that histogram buckets appear after making requests""" 528 | 529 | client.get("/200/111") 530 | client.get("/500/1111") 531 | 532 | with pytest.raises(KeyError, match="value_error"): 533 | client.get("/unhandled/123") 534 | 535 | metrics = client.get("/metrics").content.decode() 536 | 537 | assert ( 538 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="0.005",method="GET",path="/200/{test_param}",status_code="200"}""" 539 | in metrics 540 | ) 541 | assert ( 542 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="0.005",method="GET",path="/500/{test_param}",status_code="500"}""" 543 | in metrics 544 | ) 545 | assert ( 546 | """starlette_request_duration_seconds_bucket{app_name="starlette",le="0.005",method="GET",path="/unhandled/{test_param}",status_code="500"}""" 547 | in metrics 548 | ) 549 | 550 | def test_custom_root_path(self, testapp): 551 | """test that custom root_path does not affect the path grouping""" 552 | 553 | client = TestClient(testapp(skip_paths=["/health"]), root_path="/api") 554 | 555 | client.get("/200/111") 556 | client.get("/500/1111") 557 | client.get("/404/123") 558 | 559 | client.get("/api/200/111") 560 | client.get("/api/500/1111") 561 | client.get("/api/404/123") 562 | 563 | with pytest.raises(KeyError, match="value_error"): 564 | client.get("/unhandled/123") 565 | 566 | with pytest.raises(KeyError, match="value_error"): 567 | client.get("/api/unhandled/123") 568 | 569 | client.get("/mounted/test/404") 570 | client.get("/static/404") 571 | 572 | client.get("/api/mounted/test/123") 573 | client.get("/api/static/test.txt") 574 | 575 | client.get("/health") 576 | client.get("/api/health") 577 | 578 | metrics = client.get("/metrics").content.decode() 579 | 580 | assert ( 581 | """starlette_requests_total{app_name="starlette",method="GET",path="/200/{test_param}",status_code="200"} 2.0""" 582 | in metrics 583 | ) 584 | assert ( 585 | """starlette_requests_total{app_name="starlette",method="GET",path="/500/{test_param}",status_code="500"} 2.0""" 586 | in metrics 587 | ) 588 | assert ( 589 | """starlette_requests_total{app_name="starlette",method="GET",path="/unhandled/{test_param}",status_code="500"} 2.0""" 590 | in metrics 591 | ) 592 | assert ( 593 | """starlette_requests_total{app_name="starlette",method="GET",path="/mounted/test/{item}",status_code="200"} 1.0""" 594 | in metrics 595 | ) 596 | assert ( 597 | """starlette_requests_total{app_name="starlette",method="GET",path="/static",status_code="200"} 1.0""" 598 | in metrics 599 | ) 600 | assert ( 601 | """starlette_requests_total{app_name="starlette",method="GET",path="/static",status_code="404"} 1.0""" 602 | in metrics 603 | ) 604 | assert "/404" not in metrics 605 | assert "/health" not in metrics 606 | 607 | 608 | class TestBackgroundTasks: 609 | """tests for ensuring the middleware handles requests involving background tasks""" 610 | 611 | @pytest.fixture 612 | def client(self, testapp): 613 | return TestClient(testapp()) 614 | 615 | def test_background_task_endpoint(self, client): 616 | client.get("/background") 617 | 618 | metrics = client.get("/metrics").content.decode() 619 | background_metric = [ 620 | s 621 | for s in metrics.split("\n") 622 | if ( 623 | "starlette_request_duration_seconds_sum" in s 624 | and 'path="/background"' in s 625 | ) 626 | ] 627 | duration = background_metric[0].split("} ")[1] 628 | 629 | # the test function contains a 0.1 second background task. Ensure the metric records the response 630 | # as smaller than 0.1 second. 631 | assert float(duration) < 0.1 632 | 633 | 634 | class TestOptionalMetrics: 635 | """tests for optional additional metrics 636 | thanks to Stephen 637 | """ 638 | 639 | @pytest.fixture 640 | def client(self, testapp): 641 | return TestClient( 642 | testapp(optional_metrics=[response_body_size, request_body_size]) 643 | ) 644 | 645 | def test_response_body_size(self, client): 646 | client.get("/200") 647 | 648 | metrics = client.get("/metrics").content.decode() 649 | response_size_metric = [ 650 | s 651 | for s in metrics.split("\n") 652 | if ("starlette_response_body_bytes_total" in s and 'path="/200"' in s) 653 | ] 654 | response_size = response_size_metric[0].split("} ")[1] 655 | assert float(response_size) > 0.1 656 | 657 | def test_receive_body_size(self, client): 658 | client.post("/200", json={"test_post": ["d", "a"]}) 659 | 660 | metrics = client.get("/metrics").content.decode() 661 | rec_size_metric = [ 662 | s 663 | for s in metrics.split("\n") 664 | if ("starlette_request_body_bytes_total" in s and 'path="/200"' in s) 665 | ] 666 | rec_size = rec_size_metric[0].split("} ")[1] 667 | assert float(rec_size) > 0.1 668 | 669 | 670 | class TestAlwaysUseIntStatus: 671 | """Tests for always_use_int_status flag""" 672 | 673 | def test_200_with_always_use_int_status_set(self, testapp): 674 | """test that even though the endpoint resturns a response with HTTP status it is converted to 200""" 675 | client = TestClient(testapp(always_use_int_status=True)) 676 | client.get("/200_or_httpstatus/OK") 677 | metrics = client.get("/metrics").content.decode() 678 | 679 | assert ( 680 | """starlette_requests_total{app_name="starlette",method="GET",path="/200_or_httpstatus/{test_param}",status_code="200"} 1.0""" 681 | in metrics 682 | ), metrics 683 | 684 | def test_200_always_use_int_status_set(self, testapp): 685 | """Test that status_code metric is 200 if status_code=200 in the response and always_use_int_status is set""" 686 | client = TestClient(testapp(always_use_int_status=True)) 687 | client.get("/200") 688 | metrics = client.get("/metrics").content.decode() 689 | 690 | assert ( 691 | """starlette_requests_total{app_name="starlette",method="GET",path="/200",status_code="200"} 1.0""" 692 | in metrics 693 | ), metrics 694 | 695 | 696 | class TestDefaultLabels: 697 | """tests for the default labels option (`labels` argument on the middleware constructor)""" 698 | 699 | def test_str_default_labels(self, testapp): 700 | """test setting default labels with string values""" 701 | labels = {"foo": "bar", "hello": "world"} 702 | client = TestClient(testapp(labels=labels)) 703 | client.get("/200") 704 | metrics = client.get("/metrics").content.decode() 705 | 706 | assert ( 707 | """starlette_requests_total{app_name="starlette",foo="bar",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 708 | in metrics 709 | ), metrics 710 | 711 | def test_callable_default_values(self, testapp): 712 | """test using callables for the default value""" 713 | 714 | # set up a callable that retrieves a header value from the request 715 | f = lambda x: x.headers.get("foo") 716 | 717 | labels = {"foo": f, "hello": "world"} 718 | 719 | client = TestClient(testapp(labels=labels)) 720 | client.get("/200", headers={"foo": "bar"}) 721 | metrics = client.get("/metrics").content.decode() 722 | 723 | assert ( 724 | """starlette_requests_total{app_name="starlette",foo="bar",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 725 | in metrics 726 | ), metrics 727 | 728 | def test_async_callable(self, testapp): 729 | """test that we can use an async callable to populate label values""" 730 | 731 | async def async_bar(request): 732 | return "bar" 733 | 734 | labels = { 735 | "bar": async_bar, 736 | "hello": "world", 737 | } 738 | client = TestClient(testapp(labels=labels)) 739 | client.get("/200") 740 | metrics = client.get("/metrics").content.decode() 741 | 742 | assert ( 743 | """starlette_requests_total{app_name="starlette",bar="bar",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 744 | in metrics 745 | ), metrics 746 | 747 | def test_from_header(self, testapp): 748 | """test with the library-provided from_header function""" 749 | labels = {"foo": from_header("foo"), "hello": "world"} 750 | client = TestClient(testapp(labels=labels)) 751 | client.get("/200", headers={"foo": "bar"}) 752 | metrics = client.get("/metrics").content.decode() 753 | 754 | assert ( 755 | """starlette_requests_total{app_name="starlette",foo="bar",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 756 | in metrics 757 | ), metrics 758 | 759 | def test_from_header_allowed_values(self, testapp): 760 | """test with the library-provided from_header function""" 761 | labels = { 762 | "foo": from_header("foo", allowed_values=("bar", "baz")), 763 | "hello": "world", 764 | } 765 | client = TestClient(testapp(labels=labels)) 766 | client.get("/200", headers={"foo": "bar"}) 767 | metrics = client.get("/metrics").content.decode() 768 | 769 | assert ( 770 | """starlette_requests_total{app_name="starlette",foo="bar",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 771 | in metrics 772 | ), metrics 773 | 774 | def test_from_header_allowed_values_disallowed_value(self, testapp): 775 | """test with the library-provided from_header function""" 776 | 777 | labels = { 778 | "foo": from_header("foo", allowed_values=("bar", "baz")), 779 | "hello": "world", 780 | } 781 | client = TestClient(testapp(labels=labels)) 782 | client.get("/200", headers={"foo": "zounds"}) 783 | metrics = client.get("/metrics").content.decode() 784 | 785 | assert ( 786 | """starlette_requests_total{app_name="starlette",foo="zounds",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 787 | not in metrics 788 | ), metrics 789 | 790 | assert ( 791 | """starlette_requests_total{app_name="starlette",foo="",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 792 | in metrics 793 | ), metrics 794 | 795 | def test_from_response_header(self, testapp): 796 | """test with the library-provided from_response_header function""" 797 | labels = {"foo": from_response_header("foo"), "hello": "world"} 798 | client = TestClient(testapp(labels=labels)) 799 | client.get("/200") 800 | metrics = client.get("/metrics").content.decode() 801 | 802 | assert ( 803 | """starlette_requests_total{app_name="starlette",foo="baz",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 804 | in metrics 805 | ), metrics 806 | 807 | def test_from_response_header_case_insensitive(self, testapp): 808 | """test with the library-provided from_response_header function with a capitalized header key.""" 809 | labels = {"foo": from_response_header("Foo"), "hello": "world"} 810 | client = TestClient(testapp(labels=labels)) 811 | client.get("/200") 812 | metrics = client.get("/metrics").content.decode() 813 | 814 | assert ( 815 | """starlette_requests_total{app_name="starlette",foo="baz",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 816 | in metrics 817 | ), metrics 818 | 819 | def test_from_response_header_http_exception(self, testapp): 820 | """test from_response_header against an endpoint that raises an HTTPException""" 821 | labels = {"foo": from_response_header("foo"), "hello": "world"} 822 | client = TestClient(testapp(labels=labels)) 823 | client.get("/500") 824 | metrics = client.get("/metrics").content.decode() 825 | 826 | assert ( 827 | """starlette_requests_total{app_name="starlette",foo="baz",hello="world",method="GET",path="/500",status_code="500"} 1.0""" 828 | in metrics 829 | ), metrics 830 | 831 | def test_from_response_header_unhandled_exception(self, testapp): 832 | """test from_response_header function against an endpoint that raises an unhandled exception""" 833 | labels = {"foo": from_response_header("foo"), "hello": "world"} 834 | client = TestClient(testapp(labels=labels)) 835 | 836 | # make the test call. This raises an error but will still populate metrics. 837 | with pytest.raises(KeyError, match="value_error"): 838 | client.get("/unhandled") 839 | 840 | metrics = client.get("/metrics").content.decode() 841 | 842 | assert ( 843 | """starlette_requests_total{app_name="starlette",foo="",hello="world",method="GET",path="/unhandled",status_code="500"} 1.0""" 844 | in metrics 845 | ), metrics 846 | 847 | def test_from_response_header_default(self, testapp): 848 | """test with the library-provided from_response_header function, with a missing header 849 | (testing the default value)""" 850 | labels = { 851 | "foo": from_response_header("x-missing-header", default="yo"), 852 | "hello": "world", 853 | } 854 | client = TestClient(testapp(labels=labels)) 855 | client.get("/200") 856 | metrics = client.get("/metrics").content.decode() 857 | 858 | assert ( 859 | """starlette_requests_total{app_name="starlette",foo="yo",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 860 | in metrics 861 | ), metrics 862 | 863 | def test_from_response_header_allowed_values(self, testapp): 864 | """test with the library-provided from_response_header function, with a header value 865 | that matches an allowed value.""" 866 | labels = { 867 | "foo": from_response_header("foo", allowed_values=("bar", "baz")), 868 | "hello": "world", 869 | } 870 | client = TestClient(testapp(labels=labels)) 871 | client.get("/200") 872 | metrics = client.get("/metrics").content.decode() 873 | 874 | assert ( 875 | """starlette_requests_total{app_name="starlette",foo="baz",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 876 | in metrics 877 | ), metrics 878 | 879 | def test_from_response_header_allowed_values_disallowed(self, testapp): 880 | """test with the library-provided from_response_header function, with a header 881 | value that does not match any of the allowed_values""" 882 | 883 | labels = { 884 | "foo": from_response_header("foo", allowed_values=("bar", "bam")), 885 | "hello": "world", 886 | } 887 | client = TestClient(testapp(labels=labels)) 888 | client.get("/200", headers={"foo": "zounds"}) 889 | metrics = client.get("/metrics").content.decode() 890 | 891 | assert ( 892 | """starlette_requests_total{app_name="starlette",foo="baz",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 893 | not in metrics 894 | ), metrics 895 | 896 | assert ( 897 | """starlette_requests_total{app_name="starlette",foo="",hello="world",method="GET",path="/200",status_code="200"} 1.0""" 898 | in metrics 899 | ), metrics 900 | 901 | 902 | class TestExemplars: 903 | """tests for adding an exemplar to the histogram and counters""" 904 | 905 | def test_exemplar(self, testapp): 906 | """test setting default labels with string values""" 907 | 908 | # create a callable that returns a label/value pair to 909 | # be used as an exemplar. 910 | def exemplar_fn(): 911 | return {"trace_id": "abc123"} 912 | 913 | # create a label for this test so we have a unique output line 914 | labels = {"test": "exemplar"} 915 | 916 | client = TestClient(testapp(exemplars=exemplar_fn, labels=labels)) 917 | client.get("/200") 918 | 919 | metrics = client.get( 920 | "/openmetrics", headers={"Accept": "application/openmetrics-text"} 921 | ).content.decode() 922 | 923 | assert ( 924 | """starlette_requests_total{app_name="starlette",method="GET",path="/200",status_code="200",test="exemplar"} 1.0 # {trace_id="abc123"}""" 925 | in metrics 926 | ), metrics 927 | 928 | @pytest.mark.parametrize("annotated", [False, True]) 929 | def test_exemplar_request(self, testapp, annotated: bool) -> None: 930 | """test setting exemplar with request injection""" 931 | 932 | # create a callable that returns a label/value pair to 933 | # be used as an exemplar. 934 | if annotated: 935 | 936 | def exemplar_fn(r: Request): 937 | return {"trace_id": r.headers.get("trace-id", "")} 938 | 939 | else: 940 | 941 | def exemplar_fn(request): 942 | return {"trace_id": request.headers.get("trace-id", "")} 943 | 944 | # create a label for this test so we have a unique output line 945 | labels = {"test": "exemplar"} 946 | 947 | client = TestClient(testapp(exemplars=exemplar_fn, labels=labels)) 948 | client.get("/200", headers={"Trace-ID": "abc123"}) 949 | 950 | metrics = client.get( 951 | "/openmetrics", 952 | headers={"Accept": "application/openmetrics-text", "Trace-ID": "abc123"}, 953 | ).content.decode() 954 | 955 | assert ( 956 | """starlette_requests_total{app_name="starlette",method="GET",path="/200",status_code="200",test="exemplar"} 1.0 # {trace_id="abc123"}""" 957 | in metrics 958 | ), metrics 959 | --------------------------------------------------------------------------------