├── .editorconfig ├── .flake8 ├── .github └── workflows │ ├── default.yml │ ├── release-created.yml │ └── watch-started.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── aiodogstatsd ├── __init__.py ├── client.py ├── compat.py ├── contrib │ ├── __init__.py │ ├── aiohttp.py │ └── starlette.py ├── protocol.py ├── py.typed └── typedefs.py ├── docker-compose.yml ├── docs ├── frameworks │ ├── aiohttp.md │ └── starlette.md ├── index.md └── usage.md ├── examples ├── app_aiohttp.py ├── app_starlette.py ├── contextmanager.py ├── timeit.py ├── timeit_task.py └── timing.py ├── mkdocs.yml ├── pyproject.toml ├── pytest.ini └── tests ├── conftest.py ├── test_client.py ├── test_contrib_aiohttp.py ├── test_contrib_starlette.py └── test_protocol.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.{jsonnet,md,toml,yml}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [Makefile] 18 | indent_style = tab 19 | indent_size = tab 20 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 88 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: default 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: [3.7, 3.8, 3.9, "3.10"] 12 | 13 | fail-fast: true 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - uses: Gr1N/setup-poetry@v7 21 | with: 22 | poetry-version: 1.1.12 23 | - run: make install-deps 24 | - run: make lint 25 | if: matrix.python-version == 3.10 26 | - run: make test 27 | - uses: codecov/codecov-action@v1 28 | if: matrix.python-version == 3.10 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release-created.yml: -------------------------------------------------------------------------------- 1 | name: release-created 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build-docs: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: "3.10" 16 | - uses: Gr1N/setup-poetry@v7 17 | with: 18 | poetry-version: 1.1.12 19 | - run: make install-deps 20 | - run: make docs-build 21 | - uses: peaceiris/actions-gh-pages@v2 22 | env: 23 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 24 | PUBLISH_BRANCH: gh-pages 25 | PUBLISH_DIR: ./site 26 | 27 | build-package: 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions/setup-python@v2 33 | with: 34 | python-version: "3.10" 35 | - uses: Gr1N/setup-poetry@v7 36 | with: 37 | poetry-version: 1.1.12 38 | - run: make install-deps 39 | - run: make publish 40 | env: 41 | PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} 42 | PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 43 | 44 | build-notify: 45 | runs-on: ubuntu-latest 46 | 47 | needs: [build-docs, build-package] 48 | 49 | steps: 50 | - uses: appleboy/telegram-action@0.0.7 51 | with: 52 | to: ${{ secrets.TELEGRAM_CHAT_ID }} 53 | token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 54 | format: markdown 55 | message: ${{ github.repository }} publish ${{ github.ref }} succeeded 56 | -------------------------------------------------------------------------------- /.github/workflows/watch-started.yml: -------------------------------------------------------------------------------- 1 | name: watch-started 2 | 3 | on: 4 | watch: 5 | types: [started] 6 | 7 | jobs: 8 | notify: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: appleboy/telegram-action@0.0.7 13 | with: 14 | to: ${{ secrets.TELEGRAM_CHAT_ID }} 15 | token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 16 | format: markdown 17 | message: ${{ github.repository }} starred! 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,python,visualstudiocode 3 | 4 | ### OSX ### 5 | *.DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must end with two \r 10 | Icon 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | 31 | ### Python ### 32 | # Byte-compiled / optimized / DLL files 33 | __pycache__/ 34 | *.py[cod] 35 | *$py.class 36 | 37 | # C extensions 38 | *.so 39 | 40 | # Distribution / packaging 41 | .Python 42 | build/ 43 | develop-eggs/ 44 | dist/ 45 | downloads/ 46 | eggs/ 47 | .eggs/ 48 | lib/ 49 | lib64/ 50 | parts/ 51 | sdist/ 52 | var/ 53 | pip-wheel-metadata/ 54 | wheels/ 55 | *.egg-info/ 56 | .installed.cfg 57 | *.egg 58 | poetry.lock 59 | 60 | # PyInstaller 61 | # Usually these files are written by a python script from a template 62 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 63 | *.manifest 64 | *.spec 65 | 66 | # Installer logs 67 | pip-log.txt 68 | pip-delete-this-directory.txt 69 | 70 | # Unit test / coverage reports 71 | htmlcov/ 72 | .coverage 73 | .coverage.* 74 | .cache 75 | .pytest_cache/ 76 | nosetests.xml 77 | coverage.xml 78 | *.cover 79 | .hypothesis/ 80 | 81 | # Translations 82 | *.mo 83 | *.pot 84 | 85 | # Flask stuff: 86 | instance/ 87 | .webassets-cache 88 | 89 | # Scrapy stuff: 90 | .scrapy 91 | 92 | # Sphinx documentation 93 | docs/_build/ 94 | 95 | # PyBuilder 96 | target/ 97 | 98 | # Jupyter Notebook 99 | .ipynb_checkpoints 100 | 101 | # pyenv 102 | .python-version 103 | 104 | # celery beat schedule file 105 | celerybeat-schedule.* 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | 132 | ### VisualStudioCode ### 133 | .vscode/ 134 | .history 135 | 136 | 137 | # End of https://www.gitignore.io/api/osx,python,visualstudiocode 138 | 139 | ### asdf ### 140 | .tool-versions 141 | 142 | ### poetry ### 143 | get-poetry.py 144 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for aiodogstatsd 2 | 3 | ## 0.17.0 (20XX-XX-XX) 4 | 5 | ## 0.16.0 (2021-12-12) 6 | 7 | - Added Python 3.10.* support 8 | - Dropped Sanic support 9 | - Fixed AIOHTTP support, #30 10 | 11 | ## 0.15.0 (2020-12-21) 12 | 13 | - Added `.timeit_task()`, `asyncio.create_task` like function that sends timing metric when the task finishes, #29 by @aviramha 14 | - Added `threshold_ms` (Optional) to `.timeit()` for sending timing metric only when exceeds threshold, #27 by @aviramha 15 | 16 | ## 0.14.0 (2020-11-16) 17 | 18 | - Added Python 3.9.* support 19 | - Fixed `.timeit()` in case of unhandled exceptions, #26 20 | 21 | ## 0.13.0 (2020-07-29) 22 | 23 | - Added configuration option to limit pending queue size. Can be configured by passing `pending_queue_size` named argument into `aiodogstatsd.Client` class. By default: `65536`, #24 24 | 25 | ## 0.12.0 (2020-05-29) 26 | 27 | - Added `connected`, `closing` and `disconnected` client properties. Can be used to check connection state of client, #23 28 | - Bumped minimum required `Sanic` version, #23 29 | 30 | ## 0.11.0 (2020-02-21) 31 | 32 | - Updated documentation: described why 9125 port used by default, #16 33 | - Added [`Starlette`](https://www.starlette.io) framework integration helpers (middleware), #15 34 | - Fixed futures initialization. From this time futures always initialized in the same event loop, #15 35 | - Added [documentation](https://gr1n.github.io/aiodogstatsd), #18 36 | 37 | ## 0.10.0 (2019-12-03) 38 | 39 | - Fixed `MTags` type to be a `Mapping` to avoid common invariance type-checking errors, #14 by @JayH5 40 | 41 | ## 0.9.0 (2019-11-29) 42 | 43 | - Added sample rate as class attribute, for setting sample rate class-wide, #11 by @aviramha 44 | - Added timer context manager for easily timing events, #12 by @aviramha 45 | - Added Python 3.8.* support, #7 46 | 47 | ## 0.8.0 (2019-11-03) 48 | 49 | - Fixed `AIOHTTP` middleware to catch any possible exception, #6 50 | - Fixed `AIOHTTP` middleware to properly handle variable routes, #8 51 | 52 | ## 0.7.0 (2019-08-14) 53 | 54 | - Fixed `AIOHTTP` graceful shutdown, #5 by @Reskov 55 | 56 | ## 0.6.0 (2019-05-24) 57 | 58 | - **Breaking Change:** Send time in milliseconds in middlewares, #3 by @eserge 59 | 60 | ## 0.5.0 (2019-05-16) 61 | 62 | - Added [`AIOHTTP`](https://aiohttp.readthedocs.io/) framework integration helpers (cleanup context and middleware). 63 | - Added [`Sanic`](https://sanicframework.org/) framework integration helpers (listeners and middlewares). 64 | 65 | ## 0.4.0 (2019-04-29) 66 | 67 | - Added Python 3.6.* support. 68 | 69 | ## 0.3.0 (2019-04-21) 70 | 71 | - Fixed datagram format. 72 | 73 | ## 0.2.0 (2019-04-06) 74 | 75 | - Added possibility to use `aiodogstatsd.Client` as a context manager. 76 | 77 | ## 0.1.0 (2019-02-10) 78 | 79 | - Initial release. 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nikita Grishko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | POETRY ?= $(HOME)/.local/bin/poetry 2 | 3 | .PHONY: install-poetry 4 | install-poetry: 5 | @curl -sSL https://install.python-poetry.org | python - 6 | 7 | .PHONY: install-deps 8 | install-deps: 9 | @$(POETRY) install -vv --extras "aiohttp starlette" 10 | 11 | .PHONY: install 12 | install: install-poetry install-deps 13 | 14 | .PHONY: lint-black 15 | lint-black: 16 | @echo "\033[92m< linting using black...\033[0m" 17 | @$(POETRY) run black --check --diff . 18 | @echo "\033[92m> done\033[0m" 19 | @echo 20 | 21 | .PHONY: lint-flake8 22 | lint-flake8: 23 | @echo "\033[92m< linting using flake8...\033[0m" 24 | @$(POETRY) run flake8 aiodogstatsd examples tests 25 | @echo "\033[92m> done\033[0m" 26 | @echo 27 | 28 | .PHONY: lint-isort 29 | lint-isort: 30 | @echo "\033[92m< linting using isort...\033[0m" 31 | @$(POETRY) run isort --check-only --diff . 32 | @echo "\033[92m> done\033[0m" 33 | @echo 34 | 35 | .PHONY: lint-mypy 36 | lint-mypy: 37 | @echo "\033[92m< linting using mypy...\033[0m" 38 | @$(POETRY) run mypy --ignore-missing-imports --follow-imports=silent aiodogstatsd examples tests 39 | @echo "\033[92m> done\033[0m" 40 | @echo 41 | 42 | .PHONY: lint 43 | lint: lint-black lint-flake8 lint-isort lint-mypy 44 | 45 | .PHONY: test 46 | test: 47 | @$(POETRY) run pytest --cov-report=term --cov-report=html --cov-report=xml --cov=aiodogstatsd -vv $(opts) 48 | 49 | .PHONY: publish 50 | publish: 51 | @$(POETRY) publish --username=$(PYPI_USERNAME) --password=$(PYPI_PASSWORD) --build 52 | 53 | .PHONY: docs-serve 54 | docs-serve: 55 | @$(POETRY) run mkdocs serve 56 | 57 | .PHONY: docs-build 58 | docs-build: 59 | @$(POETRY) run mkdocs build 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiodogstatsd 2 | 3 | [![Build Status](https://github.com/Gr1N/aiodogstatsd/workflows/default/badge.svg)](https://github.com/Gr1N/aiodogstatsd/actions?query=workflow%3Adefault) [![codecov](https://codecov.io/gh/Gr1N/aiodogstatsd/branch/master/graph/badge.svg)](https://codecov.io/gh/Gr1N/aiodogstatsd) ![PyPI](https://img.shields.io/pypi/v/aiodogstatsd.svg?label=pypi%20version) ![PyPI - Downloads](https://img.shields.io/pypi/dm/aiodogstatsd.svg?label=pypi%20downloads) ![GitHub](https://img.shields.io/github/license/Gr1N/aiodogstatsd.svg) 4 | 5 | An asyncio-based client for sending metrics to StatsD with support of [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/) extension. 6 | 7 | Library fully tested with [statsd_exporter](https://github.com/prometheus/statsd_exporter) and supports `gauge`, `counter`, `histogram`, `distribution` and `timing` types. 8 | 9 | `aiodogstatsd` client by default uses _9125_ port. It's a default port for [statsd_exporter](https://github.com/prometheus/statsd_exporter) and it's different from _8125_ which is used by default in StatsD and [DataDog](https://www.datadoghq.com/). Initialize the client with the proper port you need if it's different from _9125_. 10 | 11 | ## Installation 12 | 13 | Just type: 14 | 15 | ```sh 16 | $ pip install aiodogstatsd 17 | ``` 18 | 19 | ## At a glance 20 | 21 | Just simply use client as a context manager and send any metric you want: 22 | 23 | ```python 24 | import asyncio 25 | 26 | import aiodogstatsd 27 | 28 | 29 | async def main(): 30 | async with aiodogstatsd.Client() as client: 31 | client.increment("users.online") 32 | 33 | 34 | asyncio.run(main()) 35 | ``` 36 | 37 | Please follow [documentation](https://gr1n.github.io/aiodogstatsd) or look at [`examples/`](https://github.com/Gr1N/aiodogstatsd/tree/master/examples) directory to find more examples of library usage, e.g. integration with [`AIOHTTP`](https://aiohttp.readthedocs.io/) or [`Starlette`](https://www.starlette.io) frameworks. 38 | 39 | ## Contributing 40 | 41 | To work on the `aiodogstatsd` codebase, you'll want to clone the project locally and install the required dependencies via [poetry](https://poetry.eustace.io): 42 | 43 | ```sh 44 | $ git clone git@github.com:Gr1N/aiodogstatsd.git 45 | $ make install 46 | ``` 47 | 48 | To run tests and linters use command below: 49 | 50 | ```sh 51 | $ make lint && make test 52 | ``` 53 | 54 | If you want to run only tests or linters you can explicitly specify which test environment you want to run, e.g.: 55 | 56 | ```sh 57 | $ make lint-black 58 | ``` 59 | 60 | ## License 61 | 62 | `aiodogstatsd` is licensed under the MIT license. See the license file for details. 63 | -------------------------------------------------------------------------------- /aiodogstatsd/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client 2 | 3 | __all__ = ("Client",) 4 | -------------------------------------------------------------------------------- /aiodogstatsd/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from asyncio.transports import DatagramTransport 3 | from contextlib import contextmanager 4 | from random import random 5 | from typing import Any, Awaitable, Iterator, Optional, TypeVar 6 | 7 | from aiodogstatsd import protocol, typedefs 8 | from aiodogstatsd.compat import get_event_loop 9 | 10 | __all__ = ("Client",) 11 | 12 | _T = TypeVar("_T") 13 | 14 | 15 | class Client: 16 | __slots__ = ( 17 | "_host", 18 | "_port", 19 | "_namespace", 20 | "_constant_tags", 21 | "_state", 22 | "_protocol", 23 | "_pending_queue", 24 | "_pending_queue_size", 25 | "_listen_future", 26 | "_listen_future_join", 27 | "_read_timeout", 28 | "_close_timeout", 29 | "_sample_rate", 30 | ) 31 | 32 | @property 33 | def connected(self) -> bool: 34 | return self._state == typedefs.CState.CONNECTED 35 | 36 | @property 37 | def closing(self) -> bool: 38 | return self._state == typedefs.CState.CLOSING 39 | 40 | @property 41 | def disconnected(self) -> bool: 42 | return self._state == typedefs.CState.DISCONNECTED 43 | 44 | def __init__( 45 | self, 46 | *, 47 | host: str = "localhost", 48 | port: int = 9125, 49 | namespace: Optional[typedefs.MNamespace] = None, 50 | constant_tags: Optional[typedefs.MTags] = None, 51 | read_timeout: float = 0.5, 52 | close_timeout: Optional[float] = None, 53 | sample_rate: typedefs.MSampleRate = 1, 54 | pending_queue_size: int = 2 ** 16, 55 | ) -> None: 56 | """ 57 | Initialize a client object. 58 | 59 | You can pass `host` and `port` of the DogStatsD server, `namespace` to prefix 60 | all metric names, `constant_tags` to attach to all metrics. 61 | 62 | Also, you can specify: `read_timeout` which will be used to read messages from 63 | an AsyncIO queue; `close_timeout` which will be used as wait time for client 64 | closing; `sample_rate` can be used for adjusting the frequency of stats sending. 65 | """ 66 | self._host = host 67 | self._port = port 68 | self._namespace = namespace 69 | self._constant_tags = constant_tags or {} 70 | 71 | self._state = typedefs.CState.DISCONNECTED 72 | 73 | self._protocol = DatagramProtocol() 74 | 75 | self._pending_queue: asyncio.Queue 76 | self._pending_queue_size = pending_queue_size 77 | 78 | self._listen_future: asyncio.Future 79 | self._listen_future_join: asyncio.Future 80 | 81 | self._read_timeout = read_timeout 82 | self._close_timeout = close_timeout 83 | self._sample_rate = sample_rate 84 | 85 | async def __aenter__(self) -> "Client": 86 | await self.connect() 87 | return self 88 | 89 | async def __aexit__(self, *args) -> None: 90 | await self.close() 91 | 92 | async def connect(self) -> None: 93 | loop = get_event_loop() 94 | await loop.create_datagram_endpoint( 95 | lambda: self._protocol, remote_addr=(self._host, self._port) 96 | ) 97 | 98 | self._pending_queue = asyncio.Queue(maxsize=self._pending_queue_size) 99 | self._listen_future = asyncio.ensure_future(self._listen()) 100 | self._listen_future_join = asyncio.Future() 101 | 102 | self._state = typedefs.CState.CONNECTED 103 | 104 | async def close(self) -> None: 105 | self._state = typedefs.CState.CLOSING 106 | 107 | try: 108 | await asyncio.wait_for(self._close(), timeout=self._close_timeout) 109 | except asyncio.TimeoutError: 110 | pass 111 | 112 | self._state = typedefs.CState.DISCONNECTED 113 | 114 | async def _close(self) -> None: 115 | await self._listen_future_join 116 | self._listen_future.cancel() 117 | 118 | await self._protocol.close() 119 | 120 | def gauge( 121 | self, 122 | name: typedefs.MName, 123 | *, 124 | value: typedefs.MValue, 125 | tags: Optional[typedefs.MTags] = None, 126 | sample_rate: Optional[typedefs.MSampleRate] = None, 127 | ) -> None: 128 | """ 129 | Record the value of a gauge, optionally setting tags and a sample rate. 130 | """ 131 | self._report(name, typedefs.MType.GAUGE, value, tags, sample_rate) 132 | 133 | def increment( 134 | self, 135 | name: typedefs.MName, 136 | *, 137 | value: typedefs.MValue = 1, 138 | tags: Optional[typedefs.MTags] = None, 139 | sample_rate: Optional[typedefs.MSampleRate] = None, 140 | ) -> None: 141 | """ 142 | Increment a counter, optionally setting a value, tags and a sample rate. 143 | """ 144 | self._report(name, typedefs.MType.COUNTER, value, tags, sample_rate) 145 | 146 | def decrement( 147 | self, 148 | name: typedefs.MName, 149 | *, 150 | value: typedefs.MValue = 1, 151 | tags: Optional[typedefs.MTags] = None, 152 | sample_rate: Optional[typedefs.MSampleRate] = None, 153 | ) -> None: 154 | """ 155 | Decrement a counter, optionally setting a value, tags and a sample rate. 156 | """ 157 | value = -value if value else value 158 | self._report(name, typedefs.MType.COUNTER, value, tags, sample_rate) 159 | 160 | def histogram( 161 | self, 162 | name: typedefs.MName, 163 | *, 164 | value: typedefs.MValue, 165 | tags: Optional[typedefs.MTags] = None, 166 | sample_rate: Optional[typedefs.MSampleRate] = None, 167 | ) -> None: 168 | """ 169 | Sample a histogram value, optionally setting tags and a sample rate. 170 | """ 171 | self._report(name, typedefs.MType.HISTOGRAM, value, tags, sample_rate) 172 | 173 | def distribution( 174 | self, 175 | name: typedefs.MName, 176 | *, 177 | value: typedefs.MValue, 178 | tags: Optional[typedefs.MTags] = None, 179 | sample_rate: Optional[typedefs.MSampleRate] = None, 180 | ) -> None: 181 | """ 182 | Send a global distribution value, optionally setting tags and a sample rate. 183 | """ 184 | self._report(name, typedefs.MType.DISTRIBUTION, value, tags, sample_rate) 185 | 186 | def timing( 187 | self, 188 | name: typedefs.MName, 189 | *, 190 | value: typedefs.MValue, 191 | tags: Optional[typedefs.MTags] = None, 192 | sample_rate: Optional[typedefs.MSampleRate] = None, 193 | ) -> None: 194 | """ 195 | Record a timing, optionally setting tags and a sample rate. 196 | """ 197 | self._report(name, typedefs.MType.TIMING, value, tags, sample_rate) 198 | 199 | async def _listen(self) -> None: 200 | try: 201 | while self.connected: 202 | await self._listen_and_send() 203 | finally: 204 | # Note that `asyncio.CancelledError` raised on app clean up 205 | # Try to send remaining enqueued metrics if any 206 | while not self._pending_queue.empty(): 207 | await self._listen_and_send() 208 | self._listen_future_join.set_result(True) 209 | 210 | async def _listen_and_send(self) -> None: 211 | coro = self._pending_queue.get() 212 | 213 | try: 214 | buf = await asyncio.wait_for(coro, timeout=self._read_timeout) 215 | except asyncio.TimeoutError: 216 | pass 217 | else: 218 | self._protocol.send(buf) 219 | 220 | def _report( 221 | self, 222 | name: typedefs.MName, 223 | type_: typedefs.MType, 224 | value: typedefs.MValue, 225 | tags: Optional[typedefs.MTags] = None, 226 | sample_rate: Optional[typedefs.MSampleRate] = None, 227 | ) -> None: 228 | # Ignore any new incoming metric if client in closing or disconnected state 229 | if self.closing or self.disconnected: 230 | return 231 | 232 | sample_rate = sample_rate or self._sample_rate 233 | if sample_rate != 1 and random() > sample_rate: 234 | return 235 | 236 | # Resolve full tags list 237 | all_tags = dict(self._constant_tags, **tags or {}) 238 | 239 | # Build metric 240 | metric = protocol.build( 241 | name=name, 242 | namespace=self._namespace, 243 | value=value, 244 | type_=type_, 245 | tags=all_tags, 246 | sample_rate=sample_rate, 247 | ) 248 | 249 | # Enqueue metric 250 | try: 251 | self._pending_queue.put_nowait(metric) 252 | except asyncio.QueueFull: 253 | pass 254 | 255 | @contextmanager 256 | def timeit( 257 | self, 258 | name: typedefs.MName, 259 | *, 260 | tags: Optional[typedefs.MTags] = None, 261 | sample_rate: Optional[typedefs.MSampleRate] = None, 262 | threshold_ms: Optional[typedefs.MValue] = None, 263 | ) -> Iterator[None]: 264 | """ 265 | Context manager for easily timing methods. 266 | """ 267 | loop = get_event_loop() 268 | started_at = loop.time() 269 | 270 | try: 271 | yield 272 | finally: 273 | value = (loop.time() - started_at) * 1000 274 | if not threshold_ms or value > threshold_ms: 275 | self.timing(name, value=int(value), tags=tags, sample_rate=sample_rate) 276 | 277 | def timeit_task( 278 | self, 279 | coro: Awaitable[_T], 280 | name: typedefs.MName, 281 | *, 282 | tags: Optional[typedefs.MTags] = None, 283 | sample_rate: Optional[typedefs.MSampleRate] = None, 284 | threshold_ms: Optional[typedefs.MValue] = None, 285 | ) -> "asyncio.Task[_T]": 286 | """ 287 | Creates a task and returns it, adds a done callback for sending time metric when 288 | done and if exceeds threshold. 289 | """ 290 | loop = get_event_loop() 291 | started_at = loop.time() 292 | 293 | def _callback(_: Any) -> None: 294 | duration = (loop.time() - started_at) * 1000 295 | if threshold_ms and duration < threshold_ms: 296 | return 297 | self.timing(name, value=int(duration), tags=tags, sample_rate=sample_rate) 298 | 299 | task = loop.create_task(coro) 300 | task.add_done_callback(_callback) 301 | return task 302 | 303 | 304 | class DatagramProtocol(asyncio.DatagramProtocol): 305 | __slots__ = ("_transport", "_closed") 306 | 307 | def __init__(self) -> None: 308 | self._transport: Optional[DatagramTransport] = None 309 | self._closed: asyncio.Future 310 | 311 | async def close(self) -> None: 312 | if self._transport is None: 313 | return 314 | 315 | self._transport.close() 316 | await self._closed 317 | 318 | def connection_made(self, transport): 319 | self._transport = transport 320 | self._closed = asyncio.Future() 321 | 322 | def connection_lost(self, _exc): 323 | self._transport = None 324 | self._closed.set_result(True) 325 | 326 | def send(self, data: bytes) -> None: 327 | if self._transport is None: 328 | return 329 | 330 | try: 331 | self._transport.sendto(data) 332 | except Exception: 333 | # Errors should fail silently so they don't affect anything else 334 | pass 335 | -------------------------------------------------------------------------------- /aiodogstatsd/compat.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | __all__ = ("get_event_loop",) 5 | 6 | 7 | def _get_event_loop_factory(): # pragma: no cover 8 | if sys.version_info >= (3, 7): 9 | return asyncio.get_running_loop # type: ignore 10 | 11 | return asyncio.get_event_loop 12 | 13 | 14 | get_event_loop = _get_event_loop_factory() 15 | -------------------------------------------------------------------------------- /aiodogstatsd/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gr1N/aiodogstatsd/4c363d795df04d1cc4c137307b7f91592224ed32/aiodogstatsd/contrib/__init__.py -------------------------------------------------------------------------------- /aiodogstatsd/contrib/aiohttp.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import AsyncIterator, Awaitable, Callable, Optional, cast 3 | 4 | from aiohttp import web 5 | from aiohttp.web_urldispatcher import DynamicResource, MatchInfoError 6 | 7 | from aiodogstatsd import Client, typedefs 8 | from aiodogstatsd.compat import get_event_loop 9 | 10 | __all__ = ( 11 | "DEFAULT_CLIENT_APP_KEY", 12 | "DEAFULT_REQUEST_DURATION_METRIC_NAME", 13 | "cleanup_context_factory", 14 | "middleware_factory", 15 | ) 16 | 17 | 18 | DEFAULT_CLIENT_APP_KEY = "statsd" 19 | DEAFULT_REQUEST_DURATION_METRIC_NAME = "http_request_duration" 20 | 21 | 22 | _THandler = Callable[[web.Request], Awaitable[web.StreamResponse]] 23 | _TMiddleware = Callable[[web.Request, _THandler], Awaitable[web.StreamResponse]] 24 | 25 | 26 | def cleanup_context_factory( 27 | *, 28 | client_app_key: str = DEFAULT_CLIENT_APP_KEY, 29 | host: str = "localhost", 30 | port: int = 9125, 31 | namespace: Optional[typedefs.MNamespace] = None, 32 | constant_tags: Optional[typedefs.MTags] = None, 33 | read_timeout: float = 0.5, 34 | close_timeout: Optional[float] = None, 35 | sample_rate: typedefs.MSampleRate = 1, 36 | ) -> Callable[[web.Application], AsyncIterator[None]]: 37 | async def cleanup_context(app: web.Application) -> AsyncIterator[None]: 38 | app[client_app_key] = Client( 39 | host=host, 40 | port=port, 41 | namespace=namespace, 42 | constant_tags=constant_tags, 43 | read_timeout=read_timeout, 44 | close_timeout=close_timeout, 45 | sample_rate=sample_rate, 46 | ) 47 | await app[client_app_key].connect() 48 | yield 49 | await app[client_app_key].close() 50 | 51 | return cleanup_context 52 | 53 | 54 | def middleware_factory( 55 | *, 56 | client_app_key: str = DEFAULT_CLIENT_APP_KEY, 57 | request_duration_metric_name: str = DEAFULT_REQUEST_DURATION_METRIC_NAME, 58 | collect_not_allowed: bool = False, 59 | collect_not_found: bool = False, 60 | ) -> _TMiddleware: 61 | @web.middleware 62 | async def middleware( 63 | request: web.Request, handler: _THandler 64 | ) -> web.StreamResponse: 65 | loop = get_event_loop() 66 | request_started_at = loop.time() 67 | 68 | # By default response status is 500 because we don't want to write any logic for 69 | # catching exceptions except exceptions which inherited from 70 | # `web.HTTPException`. And also we will override response status in case of any 71 | # successful handler execution. 72 | response_status = cast(int, HTTPStatus.INTERNAL_SERVER_ERROR.value) 73 | 74 | try: 75 | response = await handler(request) 76 | response_status = response.status 77 | except web.HTTPException as e: 78 | response_status = e.status 79 | raise e 80 | finally: 81 | if _proceed_collecting( # pragma: no branch 82 | request, response_status, collect_not_allowed, collect_not_found 83 | ): 84 | request_duration = (loop.time() - request_started_at) * 1000 85 | request.app[client_app_key].timing( # pragma: no branch 86 | request_duration_metric_name, 87 | value=request_duration, 88 | tags={ 89 | "method": request.method, 90 | "path": _derive_request_path(request), 91 | "status": response_status, 92 | }, 93 | ) 94 | 95 | return response 96 | 97 | return middleware 98 | 99 | 100 | def _proceed_collecting( 101 | request: web.Request, 102 | response_status: int, 103 | collect_not_allowed: bool, 104 | collect_not_found: bool, 105 | ) -> bool: 106 | if isinstance(request.match_info, MatchInfoError) and ( 107 | (response_status == HTTPStatus.METHOD_NOT_ALLOWED and not collect_not_allowed) 108 | or (response_status == HTTPStatus.NOT_FOUND and not collect_not_found) 109 | ): 110 | return False 111 | 112 | return True 113 | 114 | 115 | def _derive_request_path(request: web.Request) -> str: 116 | # AIOHTTP has a lot of different route resources like DynamicResource and we need to 117 | # process them correctly to get a valid original request path, so if you found an 118 | # issue with the request path in your metrics then you need to go here and extend 119 | # deriving logic. 120 | if isinstance(request.match_info.route.resource, DynamicResource): 121 | return request.match_info.route.resource.canonical 122 | 123 | return request.path 124 | -------------------------------------------------------------------------------- /aiodogstatsd/contrib/starlette.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from typing import Awaitable, Callable, Optional, Tuple, cast 3 | 4 | from starlette.applications import Starlette 5 | from starlette.exceptions import HTTPException 6 | from starlette.middleware.base import BaseHTTPMiddleware 7 | from starlette.requests import Request 8 | from starlette.responses import Response 9 | from starlette.routing import Match as RouteMatch, Route, Router 10 | 11 | from aiodogstatsd import Client 12 | from aiodogstatsd.compat import get_event_loop 13 | 14 | __all__ = ( 15 | "DEAFULT_REQUEST_DURATION_METRIC_NAME", 16 | "StatsDMiddleware", 17 | ) 18 | 19 | 20 | DEAFULT_REQUEST_DURATION_METRIC_NAME = "http_request_duration" 21 | 22 | 23 | class StatsDMiddleware(BaseHTTPMiddleware): 24 | __slots__ = ( 25 | "_client", 26 | "_request_duration_metric_name", 27 | "_collect_not_allowed", 28 | "_collect_not_found", 29 | ) 30 | 31 | def __init__( 32 | self, 33 | app: Starlette, 34 | *, 35 | client: Client, 36 | request_duration_metric_name: str = DEAFULT_REQUEST_DURATION_METRIC_NAME, 37 | collect_not_allowed: bool = False, 38 | collect_not_found: bool = False, 39 | ) -> None: 40 | super().__init__(app) 41 | 42 | self._client = client 43 | self._request_duration_metric_name = request_duration_metric_name 44 | self._collect_not_allowed = collect_not_allowed 45 | self._collect_not_found = collect_not_found 46 | 47 | async def dispatch( 48 | self, request: Request, call_next: Callable[[Request], Awaitable[Response]] 49 | ) -> Response: 50 | loop = get_event_loop() 51 | request_started_at = loop.time() 52 | 53 | # By default response status is 500 because we don't want to write any logic for 54 | # catching exceptions except exceptions which inherited from `HTTPException`. 55 | # And also we will override response status in case of any successful handler 56 | # execution. 57 | response_status = cast(int, HTTPStatus.INTERNAL_SERVER_ERROR.value) 58 | 59 | try: 60 | response = await call_next(request) 61 | response_status = response.status_code 62 | except HTTPException as e: # pragma: no cover 63 | # We kept exception handling here (just in case), but code looks useless. 64 | # We're unable to cover that part of code with tests because the framework 65 | # handles exceptions somehow different, somehow deeply inside. 66 | response_status = e.status_code 67 | raise e 68 | finally: 69 | request_path, request_path_template = _derive_request_path(request) 70 | if _proceed_collecting( # pragma: no branch 71 | request_path_template, 72 | response_status, 73 | self._collect_not_allowed, 74 | self._collect_not_found, 75 | ): 76 | request_duration = (loop.time() - request_started_at) * 1000 77 | self._client.timing( # pragma: no branch 78 | self._request_duration_metric_name, 79 | value=request_duration, 80 | tags={ 81 | "method": request.method, 82 | "path": request_path_template or request_path, 83 | "status": response_status, 84 | }, 85 | ) 86 | 87 | return response 88 | 89 | 90 | def _proceed_collecting( 91 | request_path_template: Optional[str], 92 | response_status: int, 93 | collect_not_allowed: bool, 94 | collect_not_found: bool, 95 | ) -> bool: 96 | if ( 97 | request_path_template is None 98 | and response_status == HTTPStatus.NOT_FOUND 99 | and not collect_not_found 100 | ): 101 | return False 102 | elif response_status == HTTPStatus.METHOD_NOT_ALLOWED and not collect_not_allowed: 103 | return False 104 | 105 | return True 106 | 107 | 108 | def _derive_request_path(request: Request) -> Tuple[str, Optional[str]]: 109 | # We need somehow understand request path in templated view this needed in case of 110 | # parametrized routes. Current realization is not very efficient, but for now, there 111 | # is no better way to do such things. 112 | router: Router = request.scope["router"] 113 | for route in router.routes: 114 | match, _ = route.matches(request.scope) 115 | if match == RouteMatch.NONE: 116 | continue 117 | 118 | return request["path"], cast(Route, route).path 119 | 120 | return request["path"], None 121 | -------------------------------------------------------------------------------- /aiodogstatsd/protocol.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aiodogstatsd import typedefs 4 | 5 | __all__ = ("build", "build_tags") 6 | 7 | 8 | def build( 9 | *, 10 | name: typedefs.MName, 11 | namespace: Optional[typedefs.MNamespace], 12 | value: typedefs.MValue, 13 | type_: typedefs.MType, 14 | tags: typedefs.MTags, 15 | sample_rate: typedefs.MSampleRate, 16 | ) -> bytes: 17 | p_name = f"{namespace}.{name}" if namespace is not None else name 18 | p_sample_rate = f"|@{sample_rate}" if sample_rate != 1 else "" 19 | 20 | p_tags = build_tags(tags) 21 | p_tags = f"|#{p_tags}" if p_tags else "" 22 | 23 | return f"{p_name}:{value}|{type_.value}{p_sample_rate}{p_tags}".encode("utf-8") 24 | 25 | 26 | def build_tags(tags: typedefs.MTags) -> str: 27 | if not tags: 28 | return "" 29 | 30 | return ",".join(f"{k}:{v}" for k, v in tags.items()) 31 | -------------------------------------------------------------------------------- /aiodogstatsd/py.typed: -------------------------------------------------------------------------------- 1 | Marker 2 | -------------------------------------------------------------------------------- /aiodogstatsd/typedefs.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Mapping, Union 3 | 4 | __all__ = ( 5 | "CState", 6 | "MName", 7 | "MNamespace", 8 | "MType", 9 | "MValue", 10 | "MSampleRate", 11 | "MTagKey", 12 | "MTagValue", 13 | "MTags", 14 | ) 15 | 16 | 17 | MName = str 18 | MNamespace = str 19 | MValue = Union[float, int] 20 | MSampleRate = Union[float, int] 21 | 22 | MTagKey = str 23 | MTagValue = Union[float, int, str] 24 | MTags = Mapping[MTagKey, MTagValue] 25 | 26 | 27 | @enum.unique 28 | class MType(enum.Enum): 29 | COUNTER = "c" 30 | DISTRIBUTION = "d" 31 | GAUGE = "g" 32 | HISTOGRAM = "h" 33 | TIMING = "ms" 34 | 35 | 36 | @enum.unique 37 | class CState(enum.IntEnum): 38 | CONNECTED = enum.auto() 39 | CLOSING = enum.auto() 40 | DISCONNECTED = enum.auto() 41 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | stastd_exporter: 5 | image: prom/statsd-exporter:v0.14.1 6 | expose: 7 | - 9125 8 | - 9102 9 | ports: 10 | - 9125:9125/udp 11 | - 9102:9102 12 | -------------------------------------------------------------------------------- /docs/frameworks/aiohttp.md: -------------------------------------------------------------------------------- 1 | # AIOHTTP 2 | 3 | `aiodogstatsd` library can be easily used with [`AIOHTTP`](https://aiohttp.readthedocs.io/) web framework by using cleanup context and middleware provided. 4 | 5 | At first you need to install `aiodogstatsd` with required extras: 6 | 7 | ```sh 8 | pip install aiodogstatsd[aiohttp] 9 | ``` 10 | 11 | Then you can use code below as is to get initialized client and middleware: 12 | 13 | ```python 14 | from aiohttp import web 15 | 16 | from aiodogstatsd.contrib import aiohttp as aiodogstatsd 17 | 18 | 19 | app = web.Application(middlewares=[aiodogstatsd.middleware_factory()]) 20 | app.cleanup_ctx.append(aiodogstatsd.cleanup_context_factory()) 21 | ``` 22 | 23 | Optionally you can provide additional configuration to the cleanup context factory: 24 | 25 | - `client_app_key` — a key to store initialized `aiodogstatsd.Client` in application context (default: `statsd`); 26 | - `host` — host string of your StatsD server (default: `localhost`); 27 | - `port` — post of your StatsD server (default: `9125`); 28 | - `namespace` — optional namespace string to prefix all metrics; 29 | - `constant_tags` — optional tags dictionary to apply to all metrics; 30 | - `read_timeout` (default: `0.5`); 31 | - `close_timeout`; 32 | - `sample_rate` (default: `1`). 33 | 34 | Optionally you can provide additional configuration to the middleware factory: 35 | 36 | - `client_app_key` — a key to lookup `aiodogstatsd.Client` in application context (default: `statsd`); 37 | - `request_duration_metric_name` — name of request duration metric (default: `http_request_duration`); 38 | - `collect_not_allowed` — collect or not `405 Method Not Allowed` responses; 39 | - `collect_not_found` — collect or not `404 Not Found` responses. 40 | -------------------------------------------------------------------------------- /docs/frameworks/starlette.md: -------------------------------------------------------------------------------- 1 | # Starlette 2 | 3 | `aiodogstatsd` library can be easily used with [`Starlette`](https://www.starlette.io) web framework by using client and middleware provided. 4 | 5 | At first you need to install `aiodogstatsd` with required extras: 6 | 7 | ```sh 8 | pip install aiodogstatsd[starlette] 9 | ``` 10 | 11 | Then you can use code below as is to get initialized client and middleware: 12 | 13 | ```python 14 | from starlette.applications import Starlette 15 | from starlette.middleware import Middleware 16 | 17 | import aiodogstatsd 18 | from aiodogstatsd.contrib.starlette import StatsDMiddleware 19 | 20 | 21 | client = aiodogstatsd.Client() 22 | 23 | app = Starlette( 24 | middleware=[Middleware(StatsDMiddleware, client=client)], 25 | on_startup=[client.connect], 26 | on_shutdown=[client.close], 27 | ) 28 | ``` 29 | 30 | Optionally you can provide additional configuration to the middleware: 31 | 32 | - `request_duration_metric_name` — name of request duration metric (default: `http_request_duration`); 33 | - `collect_not_allowed` — collect or not `405 Method Not Allowed` responses; 34 | - `collect_not_found` — collect or not `404 Not Found` responses. 35 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # aiodogstatsd 2 | 3 | [![Build Status](https://github.com/Gr1N/aiodogstatsd/workflows/default/badge.svg)](https://github.com/Gr1N/aiodogstatsd/actions?query=workflow%3Adefault) [![codecov](https://codecov.io/gh/Gr1N/aiodogstatsd/branch/master/graph/badge.svg)](https://codecov.io/gh/Gr1N/aiodogstatsd) ![PyPI](https://img.shields.io/pypi/v/aiodogstatsd.svg?label=pypi%20version) ![PyPI - Downloads](https://img.shields.io/pypi/dm/aiodogstatsd.svg?label=pypi%20downloads) ![GitHub](https://img.shields.io/github/license/Gr1N/aiodogstatsd.svg) 4 | 5 | `aiodogstatsd` is an asyncio-based client for sending metrics to StatsD with support of [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/) extension. 6 | 7 | Library fully tested with [statsd_exporter](https://github.com/prometheus/statsd_exporter) and supports `gauge`, `counter`, `histogram`, `distribution` and `timing` types. 8 | 9 | !!! info 10 | `aiodogstatsd` client by default uses _9125_ port. It's a default port for [statsd_exporter](https://github.com/prometheus/statsd_exporter) and it's different from _8125_ which is used by default in StatsD and [DataDog](https://www.datadoghq.com/). Initialize the client with the proper port you need if it's different from _9125_. 11 | 12 | ## Installation 13 | 14 | Just type: 15 | 16 | ```sh 17 | pip install aiodogstatsd 18 | ``` 19 | 20 | ...or if you're interested in integration with [`AIOHTTP`](https://aiohttp.readthedocs.io/) or [`Starlette`](https://www.starlette.io) frameworks specify corresponding extras: 21 | 22 | ```sh 23 | pip install aiodogstatsd[aiohttp,starlette] 24 | ``` 25 | 26 | ## At a glance 27 | 28 | Just simply use client as a context manager and send any metric you want: 29 | 30 | ```python 31 | import asyncio 32 | 33 | import aiodogstatsd 34 | 35 | 36 | async def main(): 37 | async with aiodogstatsd.Client() as client: 38 | client.increment("users.online") 39 | 40 | 41 | asyncio.run(main()) 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Basics 4 | 5 | `aiodogstatsd.Client` can be initialized with: 6 | 7 | - `host` — host string of your StatsD server (default: `localhost`); 8 | - `port` — post of your StatsD server (default: `9125`); 9 | - `namespace` — optional namespace string to prefix all metrics; 10 | - `constant_tags` — optional tags dictionary to apply to all metrics; 11 | - `read_timeout` (default: `0.5`); 12 | - `close_timeout`; 13 | - `sample_rate` (default: `1`). 14 | 15 | Below you can find an example of client initialization. Keep your eyes on lines 13 and 15. You always need to not to forget to initialize connection and close it at the end: 16 | 17 | ```python hl_lines="13 15" 18 | client = aiodogstatsd.Client( 19 | host="127.0.0.1", 20 | port=8125, 21 | namespace="hello", 22 | constant_tags={ 23 | "service": "auth", 24 | }, 25 | read_timeout=0.5, 26 | close_timeout=0.5, 27 | sample_rate=1, 28 | ) 29 | 30 | await client.connect() 31 | client.increment("users.online") 32 | await client.close() 33 | ``` 34 | 35 | ## Context manager 36 | 37 | As an option you can use `aiodogstatsd.Client` as a context manager. In that case you don't need to remember to initialize and close connection: 38 | 39 | ```python 40 | async with aiodogstatsd.Client() as client: 41 | client.increment("users.online") 42 | ``` 43 | 44 | ## Sending metrics 45 | 46 | ### Gauge 47 | 48 | Record the value of a gauge, optionally setting `tags` and a `sample_rate`. 49 | 50 | ```python 51 | client.gauge("users.online", value=42) 52 | ``` 53 | 54 | ### Increment 55 | 56 | Increment a counter, optionally setting a `value`, `tags` and a `sample_rate`. 57 | 58 | ```python 59 | client.increment("users.online") 60 | ``` 61 | 62 | ### Decrement 63 | 64 | Decrement a counter, optionally setting a `value`, `tags` and a `sample_rate`. 65 | 66 | ```python 67 | client.decrement("users.online") 68 | ``` 69 | 70 | ### Histogram 71 | 72 | Sample a histogram value, optionally setting `tags` and a `sample_rate`. 73 | 74 | ```python 75 | client.histogram("request.time", value=0.2) 76 | ``` 77 | 78 | ### Distribution 79 | 80 | Send a global distribution value, optionally setting `tags` and a `sample_rate`. 81 | 82 | ```python 83 | client.distribution("uploaded.file.size", value=8819) 84 | ``` 85 | 86 | ### Timing 87 | 88 | Record a timing, optionally setting `tags` and a `sample_rate`. 89 | 90 | ```python 91 | client.timing("query.time", value=0.5) 92 | ``` 93 | 94 | ### TimeIt 95 | 96 | Context manager for easily timing methods, optionally settings `tags`, `sample_rate` and `threshold_ms`. 97 | 98 | ```python 99 | with client.timeit("query.time"): 100 | ... 101 | ``` 102 | 103 | ### timeit_task 104 | 105 | Wrapper for `asyncio.create_task` that creates a task from a given `Awaitable` and sends timing metric of it's duration. 106 | 107 | ```python 108 | async def do_something(): 109 | await asyncio.sleep(1.0) 110 | await client.timeit_task(do_something(), "task.time") 111 | ``` 112 | -------------------------------------------------------------------------------- /examples/app_aiohttp.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from aiohttp import web 4 | 5 | from aiodogstatsd.contrib import aiohttp as aiodogstatsd 6 | 7 | 8 | async def handler_hello(request: web.Request) -> web.Response: 9 | return web.json_response({"hello": "aiodogstatsd"}) 10 | 11 | 12 | async def handler_bad_request(request: web.Request) -> web.Response: 13 | return web.json_response({"hello": "bad"}, status=HTTPStatus.BAD_REQUEST) 14 | 15 | 16 | async def handler_internal_server_error(request: web.Request) -> web.Response: 17 | raise NotImplementedError() 18 | 19 | 20 | async def handler_unauthorized(request: web.Request) -> web.Response: 21 | raise web.HTTPUnauthorized() 22 | 23 | 24 | def get_application() -> web.Application: 25 | app = web.Application(middlewares=[aiodogstatsd.middleware_factory()]) 26 | app.cleanup_ctx.append( 27 | aiodogstatsd.cleanup_context_factory( 28 | host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} 29 | ) 30 | ) 31 | app.add_routes( 32 | [ 33 | web.get("/hello", handler_hello), 34 | web.get("/bad_request", handler_bad_request), 35 | web.get("/internal_server_error", handler_internal_server_error), 36 | web.get("/unauthorized", handler_unauthorized), 37 | ] 38 | ) 39 | 40 | return app 41 | 42 | 43 | if __name__ == "__main__": 44 | app = get_application() 45 | web.run_app(app) 46 | -------------------------------------------------------------------------------- /examples/app_starlette.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import uvicorn 4 | from starlette.applications import Starlette 5 | from starlette.exceptions import HTTPException 6 | from starlette.middleware import Middleware 7 | from starlette.requests import Request 8 | from starlette.responses import JSONResponse 9 | from starlette.routing import Route 10 | 11 | import aiodogstatsd 12 | from aiodogstatsd.contrib.starlette import StatsDMiddleware 13 | 14 | 15 | async def handler_hello(request: Request) -> JSONResponse: 16 | return JSONResponse({"hello": "aiodogstatsd"}) 17 | 18 | 19 | async def handler_bad_request(request: Request) -> JSONResponse: 20 | return JSONResponse({"hello": "bad"}, status_code=HTTPStatus.BAD_REQUEST) 21 | 22 | 23 | async def handler_internal_server_error(request: Request) -> JSONResponse: 24 | raise NotImplementedError() 25 | 26 | 27 | async def handler_unauthorized(request: Request) -> JSONResponse: 28 | raise HTTPException(HTTPStatus.UNAUTHORIZED) 29 | 30 | 31 | def get_application() -> Starlette: 32 | client = aiodogstatsd.Client( 33 | host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} 34 | ) 35 | 36 | app = Starlette( 37 | debug=True, 38 | routes=[ 39 | Route("/hello", handler_hello), 40 | Route("/bad_request", handler_bad_request), 41 | Route("/internal_server_error", handler_internal_server_error), 42 | Route("/unauthorized", handler_unauthorized), 43 | ], 44 | middleware=[Middleware(StatsDMiddleware, client=client)], 45 | on_startup=[client.connect], 46 | on_shutdown=[client.close], 47 | ) 48 | 49 | return app 50 | 51 | 52 | if __name__ == "__main__": 53 | app = get_application() 54 | uvicorn.run(app) 55 | -------------------------------------------------------------------------------- /examples/contextmanager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from random import random 3 | 4 | import aiodogstatsd 5 | 6 | 7 | async def main(): 8 | async with aiodogstatsd.Client( 9 | host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} 10 | ) as client: 11 | for _ in range(5000): 12 | client.timing("fire", value=random()) 13 | 14 | 15 | if __name__ == "__main__": 16 | loop = asyncio.get_event_loop() 17 | loop.run_until_complete(main()) 18 | -------------------------------------------------------------------------------- /examples/timeit.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiodogstatsd 4 | 5 | 6 | async def main(): 7 | client = aiodogstatsd.Client( 8 | host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} 9 | ) 10 | await client.connect() 11 | 12 | # Use threshold_ms for setting a threshold for sending the timing metric. 13 | with client.timeit("fire"): 14 | # Do action we want to time 15 | pass 16 | 17 | await client.close() 18 | 19 | 20 | if __name__ == "__main__": 21 | loop = asyncio.get_event_loop() 22 | loop.run_until_complete(main()) 23 | -------------------------------------------------------------------------------- /examples/timeit_task.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiodogstatsd 4 | 5 | 6 | async def do_something(): 7 | await asyncio.sleep(1) 8 | 9 | 10 | async def main(): 11 | client = aiodogstatsd.Client( 12 | host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} 13 | ) 14 | await client.connect() 15 | 16 | for _ in range(5000): 17 | await client.timeit(do_something(), "task_finished") 18 | 19 | await client.close() 20 | 21 | 22 | if __name__ == "__main__": 23 | loop = asyncio.get_event_loop() 24 | loop.run_until_complete(main()) 25 | -------------------------------------------------------------------------------- /examples/timing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from random import random 3 | 4 | import aiodogstatsd 5 | 6 | 7 | async def main(): 8 | client = aiodogstatsd.Client( 9 | host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} 10 | ) 11 | await client.connect() 12 | 13 | for _ in range(5000): 14 | client.timing("fire", value=random()) 15 | 16 | await client.close() 17 | 18 | 19 | if __name__ == "__main__": 20 | loop = asyncio.get_event_loop() 21 | loop.run_until_complete(main()) 22 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: aiodogstatsd 2 | theme: 3 | name: material 4 | 5 | repo_name: Gr1N/aiodogstatsd 6 | repo_url: https://github.com/Gr1N/aiodogstatsd 7 | 8 | extra: 9 | social: 10 | - icon: fontawesome/brands/github 11 | link: https://github.com/Gr1N 12 | - icon: fontawesome/brands/linkedin 13 | link: https://linkedin.com/in/ngrishko 14 | 15 | markdown_extensions: 16 | - admonition 17 | - toc: 18 | permalink: true 19 | - tables 20 | - pymdownx.highlight: 21 | linenums: true 22 | linenums_style: pymdownx.inline 23 | - pymdownx.inlinehilite 24 | - pymdownx.superfences 25 | - pymdownx.snippets 26 | 27 | nav: 28 | - Home: index.md 29 | - Usage: usage.md 30 | - Frameworks: 31 | - AIOHTTP: frameworks/aiohttp.md 32 | - Starlette: frameworks/starlette.md 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ["py37", "py38", "py39", "py310"] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.git 8 | | \.hg 9 | | \.mypy_cache 10 | | \.tox 11 | | \.venv 12 | | \.vscode 13 | | _build 14 | | buck-out 15 | | build 16 | | dist 17 | )/ 18 | ''' 19 | 20 | [tool.coverage.run] 21 | branch = true 22 | 23 | [tool.coverage.report] 24 | exclude_lines = [ 25 | # Have to re-enable the standard pragma 26 | "pragma: no cover", 27 | # Don't complain about missing debug-only code: 28 | "def __repr__", 29 | "if self.debug", 30 | # Don't complain about some magic methods: 31 | "def __str__", 32 | # Don't complain if tests don't hit defensive assertion code: 33 | "raise AssertionError", 34 | "raise NotImplementedError", 35 | # Don't complain if non-runnable code isn't run: 36 | "if 0:", 37 | "if __name__ == .__main__.:" 38 | ] 39 | ignore_errors = true 40 | 41 | [tool.isort] 42 | combine_as_imports = true 43 | profile = "black" 44 | sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 45 | skip = ".eggs,.venv,venv" 46 | 47 | [tool.poetry] 48 | name = "aiodogstatsd" 49 | version = "0.17.0-alpha.0" 50 | description = "An asyncio-based client for sending metrics to StatsD with support of DogStatsD extension" 51 | authors = [ 52 | "Nikita Grishko " 53 | ] 54 | license = "MIT" 55 | 56 | readme = "README.md" 57 | 58 | homepage = "https://github.com/Gr1N/aiodogstatsd" 59 | repository = "https://github.com/Gr1N/aiodogstatsd" 60 | documentation = "https://gr1n.github.io/aiodogstatsd" 61 | 62 | keywords = ["asyncio", "statsd", "statsd-client", "statsd-metrics", "dogstatsd"] 63 | 64 | classifiers = [ 65 | "Topic :: Software Development :: Libraries :: Python Modules" 66 | ] 67 | 68 | [tool.poetry.dependencies] 69 | python = ">=3.7,<4.0" 70 | 71 | aiohttp = { version = ">=3.0", optional = true } 72 | starlette = { version = ">=0.13", optional = true } 73 | 74 | [tool.poetry.dev-dependencies] 75 | async-asgi-testclient = ">=1.4" 76 | black = { version = ">=21.12b0", allow-prereleases = true } 77 | coverage = { version = ">=6.2", extras = ["toml"] } 78 | flake8 = ">=4.0" 79 | flake8-bugbear = ">=21.11" 80 | isort = ">=5.10" 81 | mkdocs-material = ">=8.1" 82 | mypy = ">=0.910" 83 | pytest = ">=6.2" 84 | pytest-asyncio = ">=0.16" 85 | pytest-cov = ">=3.0" 86 | pytest-mock = ">=3.6" 87 | pytest-mockservers = ">=0.6" 88 | pytest-timeout = ">=2.0" 89 | uvicorn = ">=0.16" 90 | yarl = ">=1.7" 91 | 92 | [tool.poetry.extras] 93 | aiohttp = ["aiohttp"] 94 | starlette = ["starlette"] 95 | 96 | [build-system] 97 | requires = ["poetry_core>=1.0.0"] 98 | build-backend = "poetry.core.masonry.api" 99 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | # aiofiles 4 | ignore:"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead 5 | # aiohttp 6 | ignore:The loop argument is deprecated since Python 3.8 7 | # pytest-asyncio 8 | ignore:direct construction of Function has been deprecated 9 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import List 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | async def statsd_server(udp_server_factory, unused_udp_port): 9 | collected = [] 10 | 11 | class ServerProtocol(asyncio.DatagramProtocol): 12 | def datagram_received(self, data, addr): 13 | collected.append(data) 14 | 15 | udp_server = udp_server_factory( 16 | host="0.0.0.0", port=unused_udp_port, protocol=ServerProtocol 17 | ) 18 | 19 | yield udp_server, collected 20 | 21 | 22 | @pytest.fixture 23 | def wait_for(): 24 | async def _wait_for( 25 | collected: List[str], *, count: int = 1, attempts: int = 50 26 | ) -> None: 27 | sleep = 0.0 28 | while attempts: 29 | if len(collected) == count: 30 | break 31 | 32 | attempts -= 1 33 | await asyncio.sleep(sleep) 34 | sleep = 0.01 35 | 36 | return _wait_for 37 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | import aiodogstatsd 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | @pytest.fixture 11 | async def statsd_client(unused_udp_port): 12 | client = aiodogstatsd.Client( 13 | host="0.0.0.0", 14 | port=unused_udp_port, 15 | constant_tags={"whoami": "batman"}, 16 | ) 17 | await client.connect() 18 | yield client 19 | await client.close() 20 | 21 | 22 | @pytest.fixture 23 | async def statsd_client_samplerate(unused_udp_port): 24 | client = aiodogstatsd.Client( 25 | host="0.0.0.0", 26 | port=unused_udp_port, 27 | constant_tags={"whoami": "batman"}, 28 | sample_rate=0.3, 29 | ) 30 | await client.connect() 31 | yield client 32 | await client.close() 33 | 34 | 35 | class TestClient: 36 | async def test_gauge(self, statsd_client, statsd_server, wait_for): 37 | udp_server, collected = statsd_server 38 | 39 | async with udp_server: 40 | statsd_client.gauge("test_gauge", value=42, tags={"and": "robin"}) 41 | await wait_for(collected) 42 | 43 | assert collected == [b"test_gauge:42|g|#whoami:batman,and:robin"] 44 | 45 | async def test_increment(self, statsd_client, statsd_server, wait_for): 46 | udp_server, collected = statsd_server 47 | 48 | async with udp_server: 49 | statsd_client.increment("test_increment", tags={"and": "robin"}) 50 | await wait_for(collected) 51 | 52 | assert collected == [b"test_increment:1|c|#whoami:batman,and:robin"] 53 | 54 | async def test_decrement(self, statsd_client, statsd_server, wait_for): 55 | udp_server, collected = statsd_server 56 | 57 | async with udp_server: 58 | statsd_client.decrement("test_decrement", tags={"and": "robin"}) 59 | await wait_for(collected) 60 | 61 | assert collected == [b"test_decrement:-1|c|#whoami:batman,and:robin"] 62 | 63 | async def test_histogram(self, statsd_client, statsd_server, wait_for): 64 | udp_server, collected = statsd_server 65 | 66 | async with udp_server: 67 | statsd_client.histogram("test_histogram", value=21, tags={"and": "robin"}) 68 | await wait_for(collected) 69 | 70 | assert collected == [b"test_histogram:21|h|#whoami:batman,and:robin"] 71 | 72 | async def test_distribution(self, statsd_client, statsd_server, wait_for): 73 | udp_server, collected = statsd_server 74 | 75 | async with udp_server: 76 | statsd_client.distribution( 77 | "test_distribution", value=84, tags={"and": "robin"} 78 | ) 79 | await wait_for(collected) 80 | 81 | assert collected == [b"test_distribution:84|d|#whoami:batman,and:robin"] 82 | 83 | async def test_timing(self, statsd_client, statsd_server, wait_for): 84 | udp_server, collected = statsd_server 85 | 86 | async with udp_server: 87 | statsd_client.timing("test_timing", value=42, tags={"and": "robin"}) 88 | await wait_for(collected) 89 | 90 | assert collected == [b"test_timing:42|ms|#whoami:batman,and:robin"] 91 | 92 | async def test_skip_if_sample_rate(self, mocker, statsd_client_samplerate): 93 | mocked_queue = mocker.patch.object(statsd_client_samplerate, "_pending_queue") 94 | 95 | statsd_client_samplerate.increment("test_sample_rate_1", sample_rate=1) 96 | mocked_queue.put_nowait.assert_called_once_with( 97 | b"test_sample_rate_1:1|c|#whoami:batman" 98 | ) 99 | 100 | mocker.patch("aiodogstatsd.client.random", return_value=1) 101 | statsd_client_samplerate.increment("test_sample_rate_2", sample_rate=0.5) 102 | mocked_queue.put_nowait.assert_called_once_with( 103 | b"test_sample_rate_1:1|c|#whoami:batman" 104 | ) 105 | 106 | mocked_queue.put_nowait.reset_mock() 107 | mocker.patch("aiodogstatsd.client.random", return_value=0.4) 108 | statsd_client_samplerate.increment("test_sample_rate_4") 109 | mocked_queue.put_nowait.assert_not_called() 110 | 111 | async def test_message_send_on_close(self, mocker): 112 | statsd_client = aiodogstatsd.Client() 113 | await statsd_client.connect() 114 | 115 | mocked_queue = mocker.patch.object(statsd_client, "_pending_queue") 116 | mocked_queue.empty = mocker.Mock() 117 | mocked_queue.empty.side_effect = [0, 1] 118 | mocked_queue.get = mocker.Mock() 119 | mocked_queue.get.side_effect = asyncio.Future 120 | 121 | await asyncio.sleep(0) 122 | mocked_queue.get.assert_called_once() 123 | await statsd_client.close() 124 | 125 | assert mocked_queue.get.call_count == 2 126 | assert mocked_queue.empty.call_count == 2 127 | 128 | async def test_skip_if_closing(self, mocker): 129 | statsd_client = aiodogstatsd.Client() 130 | await statsd_client.connect() 131 | await statsd_client.close() 132 | 133 | mocked_queue = mocker.patch.object(statsd_client, "_pending_queue") 134 | statsd_client.increment("test_closing") 135 | mocked_queue.assert_not_called() 136 | 137 | async def test_context_manager(self, unused_udp_port, statsd_server, wait_for): 138 | udp_server, collected = statsd_server 139 | 140 | async with aiodogstatsd.Client( 141 | host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"} 142 | ) as statsd_client: 143 | async with udp_server: 144 | statsd_client.gauge("test_gauge", value=42, tags={"and": "robin"}) 145 | await wait_for(collected) 146 | 147 | assert collected == [b"test_gauge:42|g|#whoami:batman,and:robin"] 148 | 149 | async def test_timeit(self, statsd_client, statsd_server, wait_for, mocker): 150 | udp_server, collected = statsd_server 151 | 152 | loop = mocker.patch("aiodogstatsd.client.get_event_loop") 153 | loop.return_value.time.return_value = 1.0 154 | with statsd_client.timeit("test_timer", tags={"and": "robin"}): 155 | loop.return_value.time.return_value = 2.0 156 | 157 | # This shouldn't be logged. 158 | loop.return_value.time.return_value = 1.0 159 | with statsd_client.timeit( 160 | "test_timer", tags={"and": "robin"}, threshold_ms=3000.0 161 | ): 162 | loop.return_value.time.return_value = 2.0 163 | 164 | async with udp_server: 165 | await wait_for(collected) 166 | assert collected == [b"test_timer:1000|ms|#whoami:batman,and:robin"] 167 | 168 | async def test_timeit_task(self, statsd_client, statsd_server, wait_for, mocker): 169 | udp_server, collected = statsd_server 170 | 171 | async def do_nothing(): 172 | pass 173 | 174 | loop = mocker.patch("aiodogstatsd.client.get_event_loop") 175 | loop.return_value.create_task = asyncio.get_event_loop().create_task 176 | 177 | # Metric will be sent 178 | loop.return_value.time.return_value = 1.0 179 | task = statsd_client.timeit_task( 180 | do_nothing(), "test_timer", tags={"and": "robin"}, threshold_ms=500 181 | ) 182 | loop.return_value.time.return_value = 2.0 183 | await task 184 | 185 | # Metric wont be sent because of not meeting the threshold 186 | loop.return_value.time.return_value = 1.0 187 | task = statsd_client.timeit_task( 188 | do_nothing(), "test_timer", tags={"and": "robin"}, threshold_ms=1100 189 | ) 190 | loop.return_value.time.return_value = 2.0 191 | await task 192 | 193 | async with udp_server: 194 | await wait_for(collected) 195 | assert collected == [b"test_timer:1000|ms|#whoami:batman,and:robin"] 196 | -------------------------------------------------------------------------------- /tests/test_contrib_aiohttp.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | from http import HTTPStatus 4 | 5 | import aiohttp 6 | import pytest 7 | from aiohttp import web 8 | from yarl import URL 9 | 10 | from aiodogstatsd.contrib import aiohttp as aiodogstatsd 11 | 12 | pytestmark = pytest.mark.asyncio 13 | 14 | 15 | def all_tasks(): 16 | if sys.version_info >= (3, 7): 17 | return asyncio.all_tasks() 18 | else: 19 | tasks = list(asyncio.Task.all_tasks()) 20 | return {t for t in tasks if not t.done()} 21 | 22 | 23 | def current_task(): 24 | if sys.version_info >= (3, 7): 25 | return asyncio.current_task() 26 | else: 27 | return asyncio.Task.current_task() 28 | 29 | 30 | @pytest.fixture(autouse=True) 31 | async def aiohttp_server(unused_tcp_port, unused_udp_port): 32 | async def handler_hello(request): 33 | return web.json_response({"hello": "aiodogstatsd"}) 34 | 35 | async def handler_hello_variable(request): 36 | return web.json_response({"hello": request.match_info["name"]}) 37 | 38 | async def handler_bad_request(request): 39 | return web.json_response({"hello": "bad"}, status=HTTPStatus.BAD_REQUEST) 40 | 41 | async def handler_internal_server_error(request): 42 | raise NotImplementedError() 43 | 44 | async def handler_unauthorized(request): 45 | raise web.HTTPUnauthorized() 46 | 47 | app = web.Application(middlewares=[aiodogstatsd.middleware_factory()]) 48 | app.cleanup_ctx.append( 49 | aiodogstatsd.cleanup_context_factory( 50 | host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"} 51 | ) 52 | ) 53 | 54 | app.add_routes( 55 | [ 56 | web.get("/hello", handler_hello), 57 | web.get("/hello/{name}", handler_hello_variable), 58 | web.post("/bad_request", handler_bad_request), 59 | web.get("/internal_server_error", handler_internal_server_error), 60 | web.get("/unauthorized", handler_unauthorized), 61 | ] 62 | ) 63 | 64 | runner = web.AppRunner(app) 65 | await runner.setup() 66 | 67 | site = web.TCPSite(runner, host="0.0.0.0", port=unused_tcp_port) 68 | await site.start() 69 | yield 70 | await runner.cleanup() 71 | 72 | 73 | @pytest.fixture 74 | def aiohttp_server_url(unused_tcp_port): 75 | return URL(f"http://0.0.0.0:{unused_tcp_port}") 76 | 77 | 78 | @pytest.fixture(autouse=True) 79 | def mock_loop_time(mocker): 80 | mock_loop = mocker.Mock() 81 | mock_loop.time.side_effect = [0, 1] 82 | 83 | mocker.patch("aiodogstatsd.contrib.aiohttp.get_event_loop", return_value=mock_loop) 84 | 85 | 86 | class TestAIOHTTP: 87 | async def test_ok(self, aiohttp_server_url, statsd_server, wait_for): 88 | udp_server, collected = statsd_server 89 | 90 | async with udp_server: 91 | async with aiohttp.ClientSession() as session: 92 | async with session.get(aiohttp_server_url / "hello") as resp: 93 | assert resp.status == HTTPStatus.OK 94 | 95 | await wait_for(collected) 96 | 97 | assert collected == [ 98 | b"http_request_duration:1000|ms" 99 | b"|#whoami:batman,method:GET,path:/hello,status:200" 100 | ] 101 | 102 | async def test_ok_variable_route(self, aiohttp_server_url, statsd_server, wait_for): 103 | udp_server, collected = statsd_server 104 | 105 | async with udp_server: 106 | async with aiohttp.ClientSession() as session: 107 | async with session.get(aiohttp_server_url / "hello" / "batman") as resp: 108 | assert resp.status == HTTPStatus.OK 109 | 110 | await wait_for(collected) 111 | 112 | assert collected == [ 113 | b"http_request_duration:1000|ms" 114 | b"|#whoami:batman,method:GET,path:/hello/{name},status:200" 115 | ] 116 | 117 | async def test_bad_request(self, aiohttp_server_url, statsd_server, wait_for): 118 | udp_server, collected = statsd_server 119 | 120 | async with udp_server: 121 | async with aiohttp.ClientSession() as session: 122 | async with session.post(aiohttp_server_url / "bad_request") as resp: 123 | assert resp.status == HTTPStatus.BAD_REQUEST 124 | 125 | await wait_for(collected) 126 | 127 | assert collected == [ 128 | b"http_request_duration:1000|ms" 129 | b"|#whoami:batman,method:POST,path:/bad_request,status:400" 130 | ] 131 | 132 | async def test_internal_server_error( 133 | self, aiohttp_server_url, statsd_server, wait_for 134 | ): 135 | udp_server, collected = statsd_server 136 | 137 | async with udp_server: 138 | async with aiohttp.ClientSession() as session: 139 | async with session.get( 140 | aiohttp_server_url / "internal_server_error" 141 | ) as resp: 142 | assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR 143 | 144 | await wait_for(collected) 145 | 146 | assert collected == [ 147 | b"http_request_duration:1000|ms" 148 | b"|#whoami:batman,method:GET,path:/internal_server_error,status:500" 149 | ] 150 | 151 | async def test_unauthorized(self, aiohttp_server_url, statsd_server, wait_for): 152 | udp_server, collected = statsd_server 153 | 154 | async with udp_server: 155 | async with aiohttp.ClientSession() as session: 156 | async with session.get(aiohttp_server_url / "unauthorized") as resp: 157 | assert resp.status == HTTPStatus.UNAUTHORIZED 158 | 159 | await wait_for(collected) 160 | 161 | assert collected == [ 162 | b"http_request_duration:1000|ms" 163 | b"|#whoami:batman,method:GET,path:/unauthorized,status:401" 164 | ] 165 | 166 | async def test_not_allowed(self, aiohttp_server_url, statsd_server, wait_for): 167 | udp_server, collected = statsd_server 168 | 169 | async with udp_server: 170 | async with aiohttp.ClientSession() as session: 171 | async with session.post(aiohttp_server_url / "hello") as resp: 172 | assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED 173 | 174 | await wait_for(collected) 175 | 176 | assert collected == [] 177 | 178 | async def test_not_found(self, aiohttp_server_url, statsd_server, wait_for): 179 | udp_server, collected = statsd_server 180 | 181 | async with udp_server: 182 | async with aiohttp.ClientSession() as session: 183 | async with session.get(aiohttp_server_url / "not_found") as resp: 184 | assert resp.status == HTTPStatus.NOT_FOUND 185 | 186 | await wait_for(collected) 187 | 188 | assert collected == [] 189 | 190 | @pytest.mark.timeout(10) 191 | async def test_client_closed_correctly(self): 192 | # Simulate actual behavior of the web.run_app clean up phase: 193 | # cancel all active tasks at the end 194 | # https://git.io/fj56P 195 | 196 | tasks = all_tasks() 197 | 198 | # cancel all tasks except current 199 | test_task = current_task() 200 | for task in tasks: 201 | if task is not test_task: 202 | task.cancel() 203 | 204 | # should not hang on the end 205 | -------------------------------------------------------------------------------- /tests/test_contrib_starlette.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from async_asgi_testclient import TestClient 5 | from starlette.applications import Starlette 6 | from starlette.exceptions import HTTPException 7 | from starlette.middleware import Middleware 8 | from starlette.responses import JSONResponse 9 | from starlette.routing import Route 10 | 11 | import aiodogstatsd 12 | from aiodogstatsd.contrib.starlette import StatsDMiddleware 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | @pytest.fixture 18 | def starlette_application(unused_udp_port): 19 | async def handler_hello(request): 20 | return JSONResponse({"hello": "aiodogstatsd"}) 21 | 22 | async def handler_hello_variable(request): 23 | return JSONResponse({"hello": request.path_params["name"]}) 24 | 25 | async def handler_bad_request(request): 26 | return JSONResponse({"hello": "bad"}, status_code=HTTPStatus.BAD_REQUEST) 27 | 28 | async def handler_internal_server_error(request): 29 | raise NotImplementedError() 30 | 31 | async def handler_unauthorized(request): 32 | raise HTTPException(HTTPStatus.UNAUTHORIZED) 33 | 34 | client = aiodogstatsd.Client( 35 | host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"} 36 | ) 37 | 38 | return Starlette( 39 | debug=True, 40 | routes=[ 41 | Route("/hello", handler_hello), 42 | Route("/hello/{name}", handler_hello_variable), 43 | Route("/bad_request", handler_bad_request, methods=["POST"]), 44 | Route("/internal_server_error", handler_internal_server_error), 45 | Route("/unauthorized", handler_unauthorized), 46 | ], 47 | middleware=[Middleware(StatsDMiddleware, client=client)], 48 | on_startup=[client.connect], 49 | on_shutdown=[client.close], 50 | ) 51 | 52 | 53 | @pytest.fixture(autouse=True) 54 | def mock_loop_time(mocker): 55 | mock_loop = mocker.Mock() 56 | mock_loop.time.side_effect = [0, 1] 57 | 58 | mocker.patch( 59 | "aiodogstatsd.contrib.starlette.get_event_loop", return_value=mock_loop 60 | ) 61 | 62 | 63 | class TestStarlette: 64 | async def test_ok(self, starlette_application, statsd_server, wait_for): 65 | udp_server, collected = statsd_server 66 | 67 | async with udp_server: 68 | async with TestClient(starlette_application) as client: 69 | resp = await client.get("/hello") 70 | assert resp.status_code == HTTPStatus.OK 71 | 72 | await wait_for(collected) 73 | 74 | assert collected == [ 75 | b"http_request_duration:1000|ms" 76 | b"|#whoami:batman,method:GET,path:/hello,status:200" 77 | ] 78 | 79 | async def test_ok_variable_route( 80 | self, starlette_application, statsd_server, wait_for 81 | ): 82 | udp_server, collected = statsd_server 83 | 84 | async with udp_server: 85 | async with TestClient(starlette_application) as client: 86 | resp = await client.get("/hello/batman") 87 | assert resp.status_code == HTTPStatus.OK 88 | 89 | await wait_for(collected) 90 | 91 | assert collected == [ 92 | b"http_request_duration:1000|ms" 93 | b"|#whoami:batman,method:GET,path:/hello/{name},status:200" 94 | ] 95 | 96 | async def test_bad_request(self, starlette_application, statsd_server, wait_for): 97 | udp_server, collected = statsd_server 98 | 99 | async with udp_server: 100 | async with TestClient(starlette_application) as client: 101 | resp = await client.post("/bad_request") 102 | assert resp.status_code == HTTPStatus.BAD_REQUEST 103 | 104 | await wait_for(collected) 105 | 106 | assert collected == [ 107 | b"http_request_duration:1000|ms" 108 | b"|#whoami:batman,method:POST,path:/bad_request,status:400" 109 | ] 110 | 111 | async def test_internal_server_error( 112 | self, starlette_application, statsd_server, wait_for 113 | ): 114 | udp_server, collected = statsd_server 115 | 116 | async with udp_server: 117 | async with TestClient(starlette_application) as client: 118 | # Here we can't check proper response status code due to realization of 119 | # test client. 120 | with pytest.raises(NotImplementedError): 121 | await client.get("/internal_server_error") 122 | 123 | await wait_for(collected) 124 | 125 | assert collected == [ 126 | b"http_request_duration:1000|ms" 127 | b"|#whoami:batman,method:GET,path:/internal_server_error,status:500" 128 | ] 129 | 130 | async def test_unauthorized(self, starlette_application, statsd_server, wait_for): 131 | udp_server, collected = statsd_server 132 | 133 | async with udp_server: 134 | async with TestClient(starlette_application) as client: 135 | resp = await client.get("/unauthorized") 136 | assert resp.status_code == HTTPStatus.UNAUTHORIZED 137 | 138 | await wait_for(collected) 139 | 140 | assert collected == [ 141 | b"http_request_duration:1000|ms" 142 | b"|#whoami:batman,method:GET,path:/unauthorized,status:401" 143 | ] 144 | 145 | async def test_not_allowed(self, starlette_application, statsd_server, wait_for): 146 | udp_server, collected = statsd_server 147 | 148 | async with udp_server: 149 | async with TestClient(starlette_application) as client: 150 | resp = await client.post("/hello") 151 | assert resp.status_code == HTTPStatus.METHOD_NOT_ALLOWED 152 | 153 | await wait_for(collected) 154 | 155 | assert collected == [] 156 | 157 | async def test_not_found(self, starlette_application, statsd_server, wait_for): 158 | udp_server, collected = statsd_server 159 | 160 | async with udp_server: 161 | async with TestClient(starlette_application) as client: 162 | resp = await client.get("/not_found") 163 | assert resp.status_code == HTTPStatus.NOT_FOUND 164 | 165 | await wait_for(collected) 166 | 167 | assert collected == [] 168 | -------------------------------------------------------------------------------- /tests/test_protocol.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiodogstatsd import protocol, typedefs 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "in_, out", 8 | ( 9 | ( 10 | { 11 | "name": "name_1", 12 | "namespace": None, 13 | "value": "value_1", 14 | "type_": typedefs.MType.COUNTER, 15 | "tags": {}, 16 | "sample_rate": 1, 17 | }, 18 | b"name_1:value_1|c", 19 | ), 20 | ( 21 | { 22 | "name": "name_2", 23 | "namespace": "namespace_2", 24 | "value": "value_2", 25 | "type_": typedefs.MType.COUNTER, 26 | "tags": {"tag_key_1": "tag_value_1", "tag_key_2": "tag_value_2"}, 27 | "sample_rate": 0.5, 28 | }, 29 | b"namespace_2.name_2:value_2|c|@0.5|#tag_key_1:tag_value_1,tag_key_2:tag_value_2", 30 | ), 31 | ), 32 | ) 33 | def test_build(in_, out): 34 | assert out == protocol.build(**in_) 35 | --------------------------------------------------------------------------------