pax_global_header00006660000000000000000000000064141554653260014525gustar00rootroot0000000000000052 comment=db65e2a9f8b404ea438d5df710f5c2f22e1e7531 aiodogstatsd-0.16.0/000077500000000000000000000000001415546532600142765ustar00rootroot00000000000000aiodogstatsd-0.16.0/.editorconfig000066400000000000000000000004141415546532600167520ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.py] indent_style = space indent_size = 4 [*.{jsonnet,md,toml,yml}] indent_style = space indent_size = 2 [Makefile] indent_style = tab indent_size = tab aiodogstatsd-0.16.0/.flake8000066400000000000000000000001531415546532600154500ustar00rootroot00000000000000[flake8] ignore = E203, E266, E501, W503 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9 aiodogstatsd-0.16.0/.github/000077500000000000000000000000001415546532600156365ustar00rootroot00000000000000aiodogstatsd-0.16.0/.github/workflows/000077500000000000000000000000001415546532600176735ustar00rootroot00000000000000aiodogstatsd-0.16.0/.github/workflows/default.yml000066400000000000000000000012471415546532600220460ustar00rootroot00000000000000name: default on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.7, 3.8, 3.9, "3.10"] fail-fast: true steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - uses: Gr1N/setup-poetry@v7 with: poetry-version: 1.1.12 - run: make install-deps - run: make lint if: matrix.python-version == 3.10 - run: make test - uses: codecov/codecov-action@v1 if: matrix.python-version == 3.10 with: token: ${{ secrets.CODECOV_TOKEN }} aiodogstatsd-0.16.0/.github/workflows/release-created.yml000066400000000000000000000025111415546532600234420ustar00rootroot00000000000000name: release-created on: release: types: [created] jobs: build-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: "3.10" - uses: Gr1N/setup-poetry@v7 with: poetry-version: 1.1.12 - run: make install-deps - run: make docs-build - uses: peaceiris/actions-gh-pages@v2 env: PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} PUBLISH_BRANCH: gh-pages PUBLISH_DIR: ./site build-package: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: "3.10" - uses: Gr1N/setup-poetry@v7 with: poetry-version: 1.1.12 - run: make install-deps - run: make publish env: PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} build-notify: runs-on: ubuntu-latest needs: [build-docs, build-package] steps: - uses: appleboy/telegram-action@0.0.7 with: to: ${{ secrets.TELEGRAM_CHAT_ID }} token: ${{ secrets.TELEGRAM_BOT_TOKEN }} format: markdown message: ${{ github.repository }} publish ${{ github.ref }} succeeded aiodogstatsd-0.16.0/.github/workflows/watch-started.yml000066400000000000000000000005331415546532600231710ustar00rootroot00000000000000name: watch-started on: watch: types: [started] jobs: notify: runs-on: ubuntu-latest steps: - uses: appleboy/telegram-action@0.0.7 with: to: ${{ secrets.TELEGRAM_CHAT_ID }} token: ${{ secrets.TELEGRAM_BOT_TOKEN }} format: markdown message: ${{ github.repository }} starred! aiodogstatsd-0.16.0/.gitignore000066400000000000000000000034501415546532600162700ustar00rootroot00000000000000 # Created by https://www.gitignore.io/api/osx,python,visualstudiocode ### OSX ### *.DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ pip-wheel-metadata/ wheels/ *.egg-info/ .installed.cfg *.egg poetry.lock # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .coverage .coverage.* .cache .pytest_cache/ nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule.* # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ ### VisualStudioCode ### .vscode/ .history # End of https://www.gitignore.io/api/osx,python,visualstudiocode ### asdf ### .tool-versions ### poetry ### get-poetry.py aiodogstatsd-0.16.0/CHANGELOG.md000066400000000000000000000046271415546532600161200ustar00rootroot00000000000000# Changelog for aiodogstatsd ## 0.16.0 (2021-12-12) - Added Python 3.10.* support - Dropped Sanic support - Fixed AIOHTTP support, #30 ## 0.15.0 (2020-12-21) - Added `.timeit_task()`, `asyncio.create_task` like function that sends timing metric when the task finishes, #29 by @aviramha - Added `threshold_ms` (Optional) to `.timeit()` for sending timing metric only when exceeds threshold, #27 by @aviramha ## 0.14.0 (2020-11-16) - Added Python 3.9.* support - Fixed `.timeit()` in case of unhandled exceptions, #26 ## 0.13.0 (2020-07-29) - 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 ## 0.12.0 (2020-05-29) - Added `connected`, `closing` and `disconnected` client properties. Can be used to check connection state of client, #23 - Bumped minimum required `Sanic` version, #23 ## 0.11.0 (2020-02-21) - Updated documentation: described why 9125 port used by default, #16 - Added [`Starlette`](https://www.starlette.io) framework integration helpers (middleware), #15 - Fixed futures initialization. From this time futures always initialized in the same event loop, #15 - Added [documentation](https://gr1n.github.io/aiodogstatsd), #18 ## 0.10.0 (2019-12-03) - Fixed `MTags` type to be a `Mapping` to avoid common invariance type-checking errors, #14 by @JayH5 ## 0.9.0 (2019-11-29) - Added sample rate as class attribute, for setting sample rate class-wide, #11 by @aviramha - Added timer context manager for easily timing events, #12 by @aviramha - Added Python 3.8.* support, #7 ## 0.8.0 (2019-11-03) - Fixed `AIOHTTP` middleware to catch any possible exception, #6 - Fixed `AIOHTTP` middleware to properly handle variable routes, #8 ## 0.7.0 (2019-08-14) - Fixed `AIOHTTP` graceful shutdown, #5 by @Reskov ## 0.6.0 (2019-05-24) - **Breaking Change:** Send time in milliseconds in middlewares, #3 by @eserge ## 0.5.0 (2019-05-16) - Added [`AIOHTTP`](https://aiohttp.readthedocs.io/) framework integration helpers (cleanup context and middleware). - Added [`Sanic`](https://sanicframework.org/) framework integration helpers (listeners and middlewares). ## 0.4.0 (2019-04-29) - Added Python 3.6.* support. ## 0.3.0 (2019-04-21) - Fixed datagram format. ## 0.2.0 (2019-04-06) - Added possibility to use `aiodogstatsd.Client` as a context manager. ## 0.1.0 (2019-02-10) - Initial release. aiodogstatsd-0.16.0/LICENSE000066400000000000000000000020571415546532600153070ustar00rootroot00000000000000MIT License Copyright (c) 2019 Nikita Grishko Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. aiodogstatsd-0.16.0/Makefile000066400000000000000000000025771415546532600157510ustar00rootroot00000000000000POETRY ?= $(HOME)/.local/bin/poetry .PHONY: install-poetry install-poetry: @curl -sSL https://install.python-poetry.org | python - .PHONY: install-deps install-deps: @$(POETRY) install -vv --extras "aiohttp starlette" .PHONY: install install: install-poetry install-deps .PHONY: lint-black lint-black: @echo "\033[92m< linting using black...\033[0m" @$(POETRY) run black --check --diff . @echo "\033[92m> done\033[0m" @echo .PHONY: lint-flake8 lint-flake8: @echo "\033[92m< linting using flake8...\033[0m" @$(POETRY) run flake8 aiodogstatsd examples tests @echo "\033[92m> done\033[0m" @echo .PHONY: lint-isort lint-isort: @echo "\033[92m< linting using isort...\033[0m" @$(POETRY) run isort --check-only --diff . @echo "\033[92m> done\033[0m" @echo .PHONY: lint-mypy lint-mypy: @echo "\033[92m< linting using mypy...\033[0m" @$(POETRY) run mypy --ignore-missing-imports --follow-imports=silent aiodogstatsd examples tests @echo "\033[92m> done\033[0m" @echo .PHONY: lint lint: lint-black lint-flake8 lint-isort lint-mypy .PHONY: test test: @$(POETRY) run pytest --cov-report=term --cov-report=html --cov-report=xml --cov=aiodogstatsd -vv $(opts) .PHONY: publish publish: @$(POETRY) publish --username=$(PYPI_USERNAME) --password=$(PYPI_PASSWORD) --build .PHONY: docs-serve docs-serve: @$(POETRY) run mkdocs serve .PHONY: docs-build docs-build: @$(POETRY) run mkdocs build aiodogstatsd-0.16.0/README.md000066400000000000000000000045711415546532600155640ustar00rootroot00000000000000# aiodogstatsd [![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) An asyncio-based client for sending metrics to StatsD with support of [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/) extension. Library fully tested with [statsd_exporter](https://github.com/prometheus/statsd_exporter) and supports `gauge`, `counter`, `histogram`, `distribution` and `timing` types. `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_. ## Installation Just type: ```sh $ pip install aiodogstatsd ``` ## At a glance Just simply use client as a context manager and send any metric you want: ```python import asyncio import aiodogstatsd async def main(): async with aiodogstatsd.Client() as client: client.increment("users.online") asyncio.run(main()) ``` 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. ## Contributing 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): ```sh $ git clone git@github.com:Gr1N/aiodogstatsd.git $ make install ``` To run tests and linters use command below: ```sh $ make lint && make test ``` If you want to run only tests or linters you can explicitly specify which test environment you want to run, e.g.: ```sh $ make lint-black ``` ## License `aiodogstatsd` is licensed under the MIT license. See the license file for details. aiodogstatsd-0.16.0/aiodogstatsd/000077500000000000000000000000001415546532600167635ustar00rootroot00000000000000aiodogstatsd-0.16.0/aiodogstatsd/__init__.py000066400000000000000000000000621415546532600210720ustar00rootroot00000000000000from .client import Client __all__ = ("Client",) aiodogstatsd-0.16.0/aiodogstatsd/client.py000066400000000000000000000235771415546532600206310ustar00rootroot00000000000000import asyncio from asyncio.transports import DatagramTransport from contextlib import contextmanager from random import random from typing import Any, Awaitable, Iterator, Optional, TypeVar from aiodogstatsd import protocol, typedefs from aiodogstatsd.compat import get_event_loop __all__ = ("Client",) _T = TypeVar("_T") class Client: __slots__ = ( "_host", "_port", "_namespace", "_constant_tags", "_state", "_protocol", "_pending_queue", "_pending_queue_size", "_listen_future", "_listen_future_join", "_read_timeout", "_close_timeout", "_sample_rate", ) @property def connected(self) -> bool: return self._state == typedefs.CState.CONNECTED @property def closing(self) -> bool: return self._state == typedefs.CState.CLOSING @property def disconnected(self) -> bool: return self._state == typedefs.CState.DISCONNECTED def __init__( self, *, host: str = "localhost", port: int = 9125, namespace: Optional[typedefs.MNamespace] = None, constant_tags: Optional[typedefs.MTags] = None, read_timeout: float = 0.5, close_timeout: Optional[float] = None, sample_rate: typedefs.MSampleRate = 1, pending_queue_size: int = 2 ** 16, ) -> None: """ Initialize a client object. You can pass `host` and `port` of the DogStatsD server, `namespace` to prefix all metric names, `constant_tags` to attach to all metrics. Also, you can specify: `read_timeout` which will be used to read messages from an AsyncIO queue; `close_timeout` which will be used as wait time for client closing; `sample_rate` can be used for adjusting the frequency of stats sending. """ self._host = host self._port = port self._namespace = namespace self._constant_tags = constant_tags or {} self._state = typedefs.CState.DISCONNECTED self._protocol = DatagramProtocol() self._pending_queue: asyncio.Queue self._pending_queue_size = pending_queue_size self._listen_future: asyncio.Future self._listen_future_join: asyncio.Future self._read_timeout = read_timeout self._close_timeout = close_timeout self._sample_rate = sample_rate async def __aenter__(self) -> "Client": await self.connect() return self async def __aexit__(self, *args) -> None: await self.close() async def connect(self) -> None: loop = get_event_loop() await loop.create_datagram_endpoint( lambda: self._protocol, remote_addr=(self._host, self._port) ) self._pending_queue = asyncio.Queue(maxsize=self._pending_queue_size) self._listen_future = asyncio.ensure_future(self._listen()) self._listen_future_join = asyncio.Future() self._state = typedefs.CState.CONNECTED async def close(self) -> None: self._state = typedefs.CState.CLOSING try: await asyncio.wait_for(self._close(), timeout=self._close_timeout) except asyncio.TimeoutError: pass self._state = typedefs.CState.DISCONNECTED async def _close(self) -> None: await self._listen_future_join self._listen_future.cancel() await self._protocol.close() def gauge( self, name: typedefs.MName, *, value: typedefs.MValue, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, ) -> None: """ Record the value of a gauge, optionally setting tags and a sample rate. """ self._report(name, typedefs.MType.GAUGE, value, tags, sample_rate) def increment( self, name: typedefs.MName, *, value: typedefs.MValue = 1, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, ) -> None: """ Increment a counter, optionally setting a value, tags and a sample rate. """ self._report(name, typedefs.MType.COUNTER, value, tags, sample_rate) def decrement( self, name: typedefs.MName, *, value: typedefs.MValue = 1, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, ) -> None: """ Decrement a counter, optionally setting a value, tags and a sample rate. """ value = -value if value else value self._report(name, typedefs.MType.COUNTER, value, tags, sample_rate) def histogram( self, name: typedefs.MName, *, value: typedefs.MValue, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, ) -> None: """ Sample a histogram value, optionally setting tags and a sample rate. """ self._report(name, typedefs.MType.HISTOGRAM, value, tags, sample_rate) def distribution( self, name: typedefs.MName, *, value: typedefs.MValue, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, ) -> None: """ Send a global distribution value, optionally setting tags and a sample rate. """ self._report(name, typedefs.MType.DISTRIBUTION, value, tags, sample_rate) def timing( self, name: typedefs.MName, *, value: typedefs.MValue, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, ) -> None: """ Record a timing, optionally setting tags and a sample rate. """ self._report(name, typedefs.MType.TIMING, value, tags, sample_rate) async def _listen(self) -> None: try: while self.connected: await self._listen_and_send() finally: # Note that `asyncio.CancelledError` raised on app clean up # Try to send remaining enqueued metrics if any while not self._pending_queue.empty(): await self._listen_and_send() self._listen_future_join.set_result(True) async def _listen_and_send(self) -> None: coro = self._pending_queue.get() try: buf = await asyncio.wait_for(coro, timeout=self._read_timeout) except asyncio.TimeoutError: pass else: self._protocol.send(buf) def _report( self, name: typedefs.MName, type_: typedefs.MType, value: typedefs.MValue, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, ) -> None: # Ignore any new incoming metric if client in closing or disconnected state if self.closing or self.disconnected: return sample_rate = sample_rate or self._sample_rate if sample_rate != 1 and random() > sample_rate: return # Resolve full tags list all_tags = dict(self._constant_tags, **tags or {}) # Build metric metric = protocol.build( name=name, namespace=self._namespace, value=value, type_=type_, tags=all_tags, sample_rate=sample_rate, ) # Enqueue metric try: self._pending_queue.put_nowait(metric) except asyncio.QueueFull: pass @contextmanager def timeit( self, name: typedefs.MName, *, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, threshold_ms: Optional[typedefs.MValue] = None, ) -> Iterator[None]: """ Context manager for easily timing methods. """ loop = get_event_loop() started_at = loop.time() try: yield finally: value = (loop.time() - started_at) * 1000 if not threshold_ms or value > threshold_ms: self.timing(name, value=int(value), tags=tags, sample_rate=sample_rate) def timeit_task( self, coro: Awaitable[_T], name: typedefs.MName, *, tags: Optional[typedefs.MTags] = None, sample_rate: Optional[typedefs.MSampleRate] = None, threshold_ms: Optional[typedefs.MValue] = None, ) -> "asyncio.Task[_T]": """ Creates a task and returns it, adds a done callback for sending time metric when done and if exceeds threshold. """ loop = get_event_loop() started_at = loop.time() def _callback(_: Any) -> None: duration = (loop.time() - started_at) * 1000 if threshold_ms and duration < threshold_ms: return self.timing(name, value=int(duration), tags=tags, sample_rate=sample_rate) task = loop.create_task(coro) task.add_done_callback(_callback) return task class DatagramProtocol(asyncio.DatagramProtocol): __slots__ = ("_transport", "_closed") def __init__(self) -> None: self._transport: Optional[DatagramTransport] = None self._closed: asyncio.Future async def close(self) -> None: if self._transport is None: return self._transport.close() await self._closed def connection_made(self, transport): self._transport = transport self._closed = asyncio.Future() def connection_lost(self, _exc): self._transport = None self._closed.set_result(True) def send(self, data: bytes) -> None: if self._transport is None: return try: self._transport.sendto(data) except Exception: # Errors should fail silently so they don't affect anything else pass aiodogstatsd-0.16.0/aiodogstatsd/compat.py000066400000000000000000000004311415546532600206160ustar00rootroot00000000000000import asyncio import sys __all__ = ("get_event_loop",) def _get_event_loop_factory(): # pragma: no cover if sys.version_info >= (3, 7): return asyncio.get_running_loop # type: ignore return asyncio.get_event_loop get_event_loop = _get_event_loop_factory() aiodogstatsd-0.16.0/aiodogstatsd/contrib/000077500000000000000000000000001415546532600204235ustar00rootroot00000000000000aiodogstatsd-0.16.0/aiodogstatsd/contrib/__init__.py000066400000000000000000000000001415546532600225220ustar00rootroot00000000000000aiodogstatsd-0.16.0/aiodogstatsd/contrib/aiohttp.py000066400000000000000000000101651415546532600224500ustar00rootroot00000000000000from http import HTTPStatus from typing import AsyncIterator, Awaitable, Callable, Optional, cast from aiohttp import web from aiohttp.web_urldispatcher import DynamicResource, MatchInfoError from aiodogstatsd import Client, typedefs from aiodogstatsd.compat import get_event_loop __all__ = ( "DEFAULT_CLIENT_APP_KEY", "DEAFULT_REQUEST_DURATION_METRIC_NAME", "cleanup_context_factory", "middleware_factory", ) DEFAULT_CLIENT_APP_KEY = "statsd" DEAFULT_REQUEST_DURATION_METRIC_NAME = "http_request_duration" _THandler = Callable[[web.Request], Awaitable[web.StreamResponse]] _TMiddleware = Callable[[web.Request, _THandler], Awaitable[web.StreamResponse]] def cleanup_context_factory( *, client_app_key: str = DEFAULT_CLIENT_APP_KEY, host: str = "localhost", port: int = 9125, namespace: Optional[typedefs.MNamespace] = None, constant_tags: Optional[typedefs.MTags] = None, read_timeout: float = 0.5, close_timeout: Optional[float] = None, sample_rate: typedefs.MSampleRate = 1, ) -> Callable[[web.Application], AsyncIterator[None]]: async def cleanup_context(app: web.Application) -> AsyncIterator[None]: app[client_app_key] = Client( host=host, port=port, namespace=namespace, constant_tags=constant_tags, read_timeout=read_timeout, close_timeout=close_timeout, sample_rate=sample_rate, ) await app[client_app_key].connect() yield await app[client_app_key].close() return cleanup_context def middleware_factory( *, client_app_key: str = DEFAULT_CLIENT_APP_KEY, request_duration_metric_name: str = DEAFULT_REQUEST_DURATION_METRIC_NAME, collect_not_allowed: bool = False, collect_not_found: bool = False, ) -> _TMiddleware: @web.middleware async def middleware( request: web.Request, handler: _THandler ) -> web.StreamResponse: loop = get_event_loop() request_started_at = loop.time() # By default response status is 500 because we don't want to write any logic for # catching exceptions except exceptions which inherited from # `web.HTTPException`. And also we will override response status in case of any # successful handler execution. response_status = cast(int, HTTPStatus.INTERNAL_SERVER_ERROR.value) try: response = await handler(request) response_status = response.status except web.HTTPException as e: response_status = e.status raise e finally: if _proceed_collecting( # pragma: no branch request, response_status, collect_not_allowed, collect_not_found ): request_duration = (loop.time() - request_started_at) * 1000 request.app[client_app_key].timing( # pragma: no branch request_duration_metric_name, value=request_duration, tags={ "method": request.method, "path": _derive_request_path(request), "status": response_status, }, ) return response return middleware def _proceed_collecting( request: web.Request, response_status: int, collect_not_allowed: bool, collect_not_found: bool, ) -> bool: if isinstance(request.match_info, MatchInfoError) and ( (response_status == HTTPStatus.METHOD_NOT_ALLOWED and not collect_not_allowed) or (response_status == HTTPStatus.NOT_FOUND and not collect_not_found) ): return False return True def _derive_request_path(request: web.Request) -> str: # AIOHTTP has a lot of different route resources like DynamicResource and we need to # process them correctly to get a valid original request path, so if you found an # issue with the request path in your metrics then you need to go here and extend # deriving logic. if isinstance(request.match_info.route.resource, DynamicResource): return request.match_info.route.resource.canonical return request.path aiodogstatsd-0.16.0/aiodogstatsd/contrib/starlette.py000066400000000000000000000101551415546532600230060ustar00rootroot00000000000000from http import HTTPStatus from typing import Awaitable, Callable, Optional, Tuple, cast from starlette.applications import Starlette from starlette.exceptions import HTTPException from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import Response from starlette.routing import Match as RouteMatch, Route, Router from aiodogstatsd import Client from aiodogstatsd.compat import get_event_loop __all__ = ( "DEAFULT_REQUEST_DURATION_METRIC_NAME", "StatsDMiddleware", ) DEAFULT_REQUEST_DURATION_METRIC_NAME = "http_request_duration" class StatsDMiddleware(BaseHTTPMiddleware): __slots__ = ( "_client", "_request_duration_metric_name", "_collect_not_allowed", "_collect_not_found", ) def __init__( self, app: Starlette, *, client: Client, request_duration_metric_name: str = DEAFULT_REQUEST_DURATION_METRIC_NAME, collect_not_allowed: bool = False, collect_not_found: bool = False, ) -> None: super().__init__(app) self._client = client self._request_duration_metric_name = request_duration_metric_name self._collect_not_allowed = collect_not_allowed self._collect_not_found = collect_not_found async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[Response]] ) -> Response: loop = get_event_loop() request_started_at = loop.time() # By default response status is 500 because we don't want to write any logic for # catching exceptions except exceptions which inherited from `HTTPException`. # And also we will override response status in case of any successful handler # execution. response_status = cast(int, HTTPStatus.INTERNAL_SERVER_ERROR.value) try: response = await call_next(request) response_status = response.status_code except HTTPException as e: # pragma: no cover # We kept exception handling here (just in case), but code looks useless. # We're unable to cover that part of code with tests because the framework # handles exceptions somehow different, somehow deeply inside. response_status = e.status_code raise e finally: request_path, request_path_template = _derive_request_path(request) if _proceed_collecting( # pragma: no branch request_path_template, response_status, self._collect_not_allowed, self._collect_not_found, ): request_duration = (loop.time() - request_started_at) * 1000 self._client.timing( # pragma: no branch self._request_duration_metric_name, value=request_duration, tags={ "method": request.method, "path": request_path_template or request_path, "status": response_status, }, ) return response def _proceed_collecting( request_path_template: Optional[str], response_status: int, collect_not_allowed: bool, collect_not_found: bool, ) -> bool: if ( request_path_template is None and response_status == HTTPStatus.NOT_FOUND and not collect_not_found ): return False elif response_status == HTTPStatus.METHOD_NOT_ALLOWED and not collect_not_allowed: return False return True def _derive_request_path(request: Request) -> Tuple[str, Optional[str]]: # We need somehow understand request path in templated view this needed in case of # parametrized routes. Current realization is not very efficient, but for now, there # is no better way to do such things. router: Router = request.scope["router"] for route in router.routes: match, _ = route.matches(request.scope) if match == RouteMatch.NONE: continue return request["path"], cast(Route, route).path return request["path"], None aiodogstatsd-0.16.0/aiodogstatsd/protocol.py000066400000000000000000000013701415546532600211770ustar00rootroot00000000000000from typing import Optional from aiodogstatsd import typedefs __all__ = ("build", "build_tags") def build( *, name: typedefs.MName, namespace: Optional[typedefs.MNamespace], value: typedefs.MValue, type_: typedefs.MType, tags: typedefs.MTags, sample_rate: typedefs.MSampleRate, ) -> bytes: p_name = f"{namespace}.{name}" if namespace is not None else name p_sample_rate = f"|@{sample_rate}" if sample_rate != 1 else "" p_tags = build_tags(tags) p_tags = f"|#{p_tags}" if p_tags else "" return f"{p_name}:{value}|{type_.value}{p_sample_rate}{p_tags}".encode("utf-8") def build_tags(tags: typedefs.MTags) -> str: if not tags: return "" return ",".join(f"{k}:{v}" for k, v in tags.items()) aiodogstatsd-0.16.0/aiodogstatsd/py.typed000066400000000000000000000000071415546532600204570ustar00rootroot00000000000000Marker aiodogstatsd-0.16.0/aiodogstatsd/typedefs.py000066400000000000000000000011731415546532600211620ustar00rootroot00000000000000import enum from typing import Mapping, Union __all__ = ( "CState", "MName", "MNamespace", "MType", "MValue", "MSampleRate", "MTagKey", "MTagValue", "MTags", ) MName = str MNamespace = str MValue = Union[float, int] MSampleRate = Union[float, int] MTagKey = str MTagValue = Union[float, int, str] MTags = Mapping[MTagKey, MTagValue] @enum.unique class MType(enum.Enum): COUNTER = "c" DISTRIBUTION = "d" GAUGE = "g" HISTOGRAM = "h" TIMING = "ms" @enum.unique class CState(enum.IntEnum): CONNECTED = enum.auto() CLOSING = enum.auto() DISCONNECTED = enum.auto() aiodogstatsd-0.16.0/docker-compose.yml000066400000000000000000000002541415546532600177340ustar00rootroot00000000000000version: "2" services: stastd_exporter: image: prom/statsd-exporter:v0.14.1 expose: - 9125 - 9102 ports: - 9125:9125/udp - 9102:9102 aiodogstatsd-0.16.0/docs/000077500000000000000000000000001415546532600152265ustar00rootroot00000000000000aiodogstatsd-0.16.0/docs/frameworks/000077500000000000000000000000001415546532600174065ustar00rootroot00000000000000aiodogstatsd-0.16.0/docs/frameworks/aiohttp.md000066400000000000000000000030611415546532600214000ustar00rootroot00000000000000# AIOHTTP `aiodogstatsd` library can be easily used with [`AIOHTTP`](https://aiohttp.readthedocs.io/) web framework by using cleanup context and middleware provided. At first you need to install `aiodogstatsd` with required extras: ```sh pip install aiodogstatsd[aiohttp] ``` Then you can use code below as is to get initialized client and middleware: ```python from aiohttp import web from aiodogstatsd.contrib import aiohttp as aiodogstatsd app = web.Application(middlewares=[aiodogstatsd.middleware_factory()]) app.cleanup_ctx.append(aiodogstatsd.cleanup_context_factory()) ``` Optionally you can provide additional configuration to the cleanup context factory: - `client_app_key` — a key to store initialized `aiodogstatsd.Client` in application context (default: `statsd`); - `host` — host string of your StatsD server (default: `localhost`); - `port` — post of your StatsD server (default: `9125`); - `namespace` — optional namespace string to prefix all metrics; - `constant_tags` — optional tags dictionary to apply to all metrics; - `read_timeout` (default: `0.5`); - `close_timeout`; - `sample_rate` (default: `1`). Optionally you can provide additional configuration to the middleware factory: - `client_app_key` — a key to lookup `aiodogstatsd.Client` in application context (default: `statsd`); - `request_duration_metric_name` — name of request duration metric (default: `http_request_duration`); - `collect_not_allowed` — collect or not `405 Method Not Allowed` responses; - `collect_not_found` — collect or not `404 Not Found` responses. aiodogstatsd-0.16.0/docs/frameworks/starlette.md000066400000000000000000000020151415546532600217350ustar00rootroot00000000000000# Starlette `aiodogstatsd` library can be easily used with [`Starlette`](https://www.starlette.io) web framework by using client and middleware provided. At first you need to install `aiodogstatsd` with required extras: ```sh pip install aiodogstatsd[starlette] ``` Then you can use code below as is to get initialized client and middleware: ```python from starlette.applications import Starlette from starlette.middleware import Middleware import aiodogstatsd from aiodogstatsd.contrib.starlette import StatsDMiddleware client = aiodogstatsd.Client() app = Starlette( middleware=[Middleware(StatsDMiddleware, client=client)], on_startup=[client.connect], on_shutdown=[client.close], ) ``` Optionally you can provide additional configuration to the middleware: - `request_duration_metric_name` — name of request duration metric (default: `http_request_duration`); - `collect_not_allowed` — collect or not `405 Method Not Allowed` responses; - `collect_not_found` — collect or not `404 Not Found` responses. aiodogstatsd-0.16.0/docs/index.md000066400000000000000000000033731415546532600166650ustar00rootroot00000000000000# aiodogstatsd [![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) `aiodogstatsd` is an asyncio-based client for sending metrics to StatsD with support of [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/) extension. Library fully tested with [statsd_exporter](https://github.com/prometheus/statsd_exporter) and supports `gauge`, `counter`, `histogram`, `distribution` and `timing` types. !!! info `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_. ## Installation Just type: ```sh pip install aiodogstatsd ``` ...or if you're interested in integration with [`AIOHTTP`](https://aiohttp.readthedocs.io/) or [`Starlette`](https://www.starlette.io) frameworks specify corresponding extras: ```sh pip install aiodogstatsd[aiohttp,starlette] ``` ## At a glance Just simply use client as a context manager and send any metric you want: ```python import asyncio import aiodogstatsd async def main(): async with aiodogstatsd.Client() as client: client.increment("users.online") asyncio.run(main()) ``` aiodogstatsd-0.16.0/docs/usage.md000066400000000000000000000047221415546532600166610ustar00rootroot00000000000000# Usage ## Basics `aiodogstatsd.Client` can be initialized with: - `host` — host string of your StatsD server (default: `localhost`); - `port` — post of your StatsD server (default: `9125`); - `namespace` — optional namespace string to prefix all metrics; - `constant_tags` — optional tags dictionary to apply to all metrics; - `read_timeout` (default: `0.5`); - `close_timeout`; - `sample_rate` (default: `1`). 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: ```python hl_lines="13 15" client = aiodogstatsd.Client( host="127.0.0.1", port=8125, namespace="hello", constant_tags={ "service": "auth", }, read_timeout=0.5, close_timeout=0.5, sample_rate=1, ) await client.connect() client.increment("users.online") await client.close() ``` ## Context manager 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: ```python async with aiodogstatsd.Client() as client: client.increment("users.online") ``` ## Sending metrics ### Gauge Record the value of a gauge, optionally setting `tags` and a `sample_rate`. ```python client.gauge("users.online", value=42) ``` ### Increment Increment a counter, optionally setting a `value`, `tags` and a `sample_rate`. ```python client.increment("users.online") ``` ### Decrement Decrement a counter, optionally setting a `value`, `tags` and a `sample_rate`. ```python client.decrement("users.online") ``` ### Histogram Sample a histogram value, optionally setting `tags` and a `sample_rate`. ```python client.histogram("request.time", value=0.2) ``` ### Distribution Send a global distribution value, optionally setting `tags` and a `sample_rate`. ```python client.distribution("uploaded.file.size", value=8819) ``` ### Timing Record a timing, optionally setting `tags` and a `sample_rate`. ```python client.timing("query.time", value=0.5) ``` ### TimeIt Context manager for easily timing methods, optionally settings `tags`, `sample_rate` and `threshold_ms`. ```python with client.timeit("query.time"): ... ``` ### timeit_task Wrapper for `asyncio.create_task` that creates a task from a given `Awaitable` and sends timing metric of it's duration. ```python async def do_something(): await asyncio.sleep(1.0) await client.timeit_task(do_something(), "task.time") ``` aiodogstatsd-0.16.0/examples/000077500000000000000000000000001415546532600161145ustar00rootroot00000000000000aiodogstatsd-0.16.0/examples/app_aiohttp.py000066400000000000000000000023661415546532600210050ustar00rootroot00000000000000from http import HTTPStatus from aiohttp import web from aiodogstatsd.contrib import aiohttp as aiodogstatsd async def handler_hello(request: web.Request) -> web.Response: return web.json_response({"hello": "aiodogstatsd"}) async def handler_bad_request(request: web.Request) -> web.Response: return web.json_response({"hello": "bad"}, status=HTTPStatus.BAD_REQUEST) async def handler_internal_server_error(request: web.Request) -> web.Response: raise NotImplementedError() async def handler_unauthorized(request: web.Request) -> web.Response: raise web.HTTPUnauthorized() def get_application() -> web.Application: app = web.Application(middlewares=[aiodogstatsd.middleware_factory()]) app.cleanup_ctx.append( aiodogstatsd.cleanup_context_factory( host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} ) ) app.add_routes( [ web.get("/hello", handler_hello), web.get("/bad_request", handler_bad_request), web.get("/internal_server_error", handler_internal_server_error), web.get("/unauthorized", handler_unauthorized), ] ) return app if __name__ == "__main__": app = get_application() web.run_app(app) aiodogstatsd-0.16.0/examples/app_starlette.py000066400000000000000000000030311415546532600213320ustar00rootroot00000000000000from http import HTTPStatus import uvicorn from starlette.applications import Starlette from starlette.exceptions import HTTPException from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Route import aiodogstatsd from aiodogstatsd.contrib.starlette import StatsDMiddleware async def handler_hello(request: Request) -> JSONResponse: return JSONResponse({"hello": "aiodogstatsd"}) async def handler_bad_request(request: Request) -> JSONResponse: return JSONResponse({"hello": "bad"}, status_code=HTTPStatus.BAD_REQUEST) async def handler_internal_server_error(request: Request) -> JSONResponse: raise NotImplementedError() async def handler_unauthorized(request: Request) -> JSONResponse: raise HTTPException(HTTPStatus.UNAUTHORIZED) def get_application() -> Starlette: client = aiodogstatsd.Client( host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} ) app = Starlette( debug=True, routes=[ Route("/hello", handler_hello), Route("/bad_request", handler_bad_request), Route("/internal_server_error", handler_internal_server_error), Route("/unauthorized", handler_unauthorized), ], middleware=[Middleware(StatsDMiddleware, client=client)], on_startup=[client.connect], on_shutdown=[client.close], ) return app if __name__ == "__main__": app = get_application() uvicorn.run(app) aiodogstatsd-0.16.0/examples/contextmanager.py000066400000000000000000000006101415546532600215020ustar00rootroot00000000000000import asyncio from random import random import aiodogstatsd async def main(): async with aiodogstatsd.Client( host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} ) as client: for _ in range(5000): client.timing("fire", value=random()) if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main()) aiodogstatsd-0.16.0/examples/timeit.py000066400000000000000000000007461415546532600177700ustar00rootroot00000000000000import asyncio import aiodogstatsd async def main(): client = aiodogstatsd.Client( host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} ) await client.connect() # Use threshold_ms for setting a threshold for sending the timing metric. with client.timeit("fire"): # Do action we want to time pass await client.close() if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main()) aiodogstatsd-0.16.0/examples/timeit_task.py000066400000000000000000000007251415546532600210070ustar00rootroot00000000000000import asyncio import aiodogstatsd async def do_something(): await asyncio.sleep(1) async def main(): client = aiodogstatsd.Client( host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} ) await client.connect() for _ in range(5000): await client.timeit(do_something(), "task_finished") await client.close() if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main()) aiodogstatsd-0.16.0/examples/timing.py000066400000000000000000000006511415546532600177570ustar00rootroot00000000000000import asyncio from random import random import aiodogstatsd async def main(): client = aiodogstatsd.Client( host="0.0.0.0", port=9125, constant_tags={"whoami": "I am Batman!"} ) await client.connect() for _ in range(5000): client.timing("fire", value=random()) await client.close() if __name__ == "__main__": loop = asyncio.get_event_loop() loop.run_until_complete(main()) aiodogstatsd-0.16.0/mkdocs.yml000066400000000000000000000010761415546532600163050ustar00rootroot00000000000000site_name: aiodogstatsd theme: name: material repo_name: Gr1N/aiodogstatsd repo_url: https://github.com/Gr1N/aiodogstatsd extra: social: - icon: fontawesome/brands/github link: https://github.com/Gr1N - icon: fontawesome/brands/linkedin link: https://linkedin.com/in/ngrishko markdown_extensions: - admonition - codehilite: guess_lang: false linenums: true - toc: permalink: true nav: - Home: index.md - Usage: usage.md - Frameworks: - AIOHTTP: frameworks/aiohttp.md - Starlette: frameworks/starlette.md aiodogstatsd-0.16.0/pyproject.toml000066400000000000000000000043241415546532600172150ustar00rootroot00000000000000[tool.black] line-length = 88 target-version = ["py37", "py38", "py39", "py310"] include = '\.pyi?$' exclude = ''' /( \.git | \.hg | \.mypy_cache | \.tox | \.venv | \.vscode | _build | buck-out | build | dist )/ ''' [tool.coverage.run] branch = true [tool.coverage.report] exclude_lines = [ # Have to re-enable the standard pragma "pragma: no cover", # Don't complain about missing debug-only code: "def __repr__", "if self.debug", # Don't complain about some magic methods: "def __str__", # Don't complain if tests don't hit defensive assertion code: "raise AssertionError", "raise NotImplementedError", # Don't complain if non-runnable code isn't run: "if 0:", "if __name__ == .__main__.:" ] ignore_errors = true [tool.isort] combine_as_imports = true profile = "black" sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" skip = ".eggs,.venv,venv" [tool.poetry] name = "aiodogstatsd" version = "0.16.0" description = "An asyncio-based client for sending metrics to StatsD with support of DogStatsD extension" authors = [ "Nikita Grishko " ] license = "MIT" readme = "README.md" homepage = "https://github.com/Gr1N/aiodogstatsd" repository = "https://github.com/Gr1N/aiodogstatsd" documentation = "https://gr1n.github.io/aiodogstatsd" keywords = ["asyncio", "statsd", "statsd-client", "statsd-metrics", "dogstatsd"] classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules" ] [tool.poetry.dependencies] python = ">=3.7,<4.0" aiohttp = { version = ">=3.0", optional = true } starlette = { version = ">=0.13", optional = true } [tool.poetry.dev-dependencies] async-asgi-testclient = ">=1.4" black = { version = ">=21.12b0", allow-prereleases = true } coverage = { version = ">=6.2", extras = ["toml"] } flake8 = ">=4.0" flake8-bugbear = ">=21.11" isort = ">=5.10" mkdocs-material = ">=8.1" mypy = ">=0.910" pytest = ">=6.2" pytest-asyncio = ">=0.16" pytest-cov = ">=3.0" pytest-mock = ">=3.6" pytest-mockservers = ">=0.6" pytest-timeout = ">=2.0" uvicorn = ">=0.16" yarl = ">=1.7" [tool.poetry.extras] aiohttp = ["aiohttp"] starlette = ["starlette"] [build-system] requires = ["poetry_core>=1.0.0"] build-backend = "poetry.core.masonry.api" aiodogstatsd-0.16.0/pytest.ini000066400000000000000000000004411415546532600163260ustar00rootroot00000000000000[pytest] filterwarnings = # aiofiles ignore:"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead # aiohttp ignore:The loop argument is deprecated since Python 3.8 # pytest-asyncio ignore:direct construction of Function has been deprecated aiodogstatsd-0.16.0/tests/000077500000000000000000000000001415546532600154405ustar00rootroot00000000000000aiodogstatsd-0.16.0/tests/conftest.py000066400000000000000000000014361415546532600176430ustar00rootroot00000000000000import asyncio from typing import List import pytest @pytest.fixture async def statsd_server(udp_server_factory, unused_udp_port): collected = [] class ServerProtocol(asyncio.DatagramProtocol): def datagram_received(self, data, addr): collected.append(data) udp_server = udp_server_factory( host="0.0.0.0", port=unused_udp_port, protocol=ServerProtocol ) yield udp_server, collected @pytest.fixture def wait_for(): async def _wait_for( collected: List[str], *, count: int = 1, attempts: int = 50 ) -> None: sleep = 0.0 while attempts: if len(collected) == count: break attempts -= 1 await asyncio.sleep(sleep) sleep = 0.01 return _wait_for aiodogstatsd-0.16.0/tests/test_client.py000066400000000000000000000155511415546532600203360ustar00rootroot00000000000000import asyncio import pytest import aiodogstatsd pytestmark = pytest.mark.asyncio @pytest.fixture async def statsd_client(unused_udp_port): client = aiodogstatsd.Client( host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"}, ) await client.connect() yield client await client.close() @pytest.fixture async def statsd_client_samplerate(unused_udp_port): client = aiodogstatsd.Client( host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"}, sample_rate=0.3, ) await client.connect() yield client await client.close() class TestClient: async def test_gauge(self, statsd_client, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: statsd_client.gauge("test_gauge", value=42, tags={"and": "robin"}) await wait_for(collected) assert collected == [b"test_gauge:42|g|#whoami:batman,and:robin"] async def test_increment(self, statsd_client, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: statsd_client.increment("test_increment", tags={"and": "robin"}) await wait_for(collected) assert collected == [b"test_increment:1|c|#whoami:batman,and:robin"] async def test_decrement(self, statsd_client, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: statsd_client.decrement("test_decrement", tags={"and": "robin"}) await wait_for(collected) assert collected == [b"test_decrement:-1|c|#whoami:batman,and:robin"] async def test_histogram(self, statsd_client, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: statsd_client.histogram("test_histogram", value=21, tags={"and": "robin"}) await wait_for(collected) assert collected == [b"test_histogram:21|h|#whoami:batman,and:robin"] async def test_distribution(self, statsd_client, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: statsd_client.distribution( "test_distribution", value=84, tags={"and": "robin"} ) await wait_for(collected) assert collected == [b"test_distribution:84|d|#whoami:batman,and:robin"] async def test_timing(self, statsd_client, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: statsd_client.timing("test_timing", value=42, tags={"and": "robin"}) await wait_for(collected) assert collected == [b"test_timing:42|ms|#whoami:batman,and:robin"] async def test_skip_if_sample_rate(self, mocker, statsd_client_samplerate): mocked_queue = mocker.patch.object(statsd_client_samplerate, "_pending_queue") statsd_client_samplerate.increment("test_sample_rate_1", sample_rate=1) mocked_queue.put_nowait.assert_called_once_with( b"test_sample_rate_1:1|c|#whoami:batman" ) mocker.patch("aiodogstatsd.client.random", return_value=1) statsd_client_samplerate.increment("test_sample_rate_2", sample_rate=0.5) mocked_queue.put_nowait.assert_called_once_with( b"test_sample_rate_1:1|c|#whoami:batman" ) mocked_queue.put_nowait.reset_mock() mocker.patch("aiodogstatsd.client.random", return_value=0.4) statsd_client_samplerate.increment("test_sample_rate_4") mocked_queue.put_nowait.assert_not_called() async def test_message_send_on_close(self, mocker): statsd_client = aiodogstatsd.Client() await statsd_client.connect() mocked_queue = mocker.patch.object(statsd_client, "_pending_queue") mocked_queue.empty = mocker.Mock() mocked_queue.empty.side_effect = [0, 1] mocked_queue.get = mocker.Mock() mocked_queue.get.side_effect = asyncio.Future await asyncio.sleep(0) mocked_queue.get.assert_called_once() await statsd_client.close() assert mocked_queue.get.call_count == 2 assert mocked_queue.empty.call_count == 2 async def test_skip_if_closing(self, mocker): statsd_client = aiodogstatsd.Client() await statsd_client.connect() await statsd_client.close() mocked_queue = mocker.patch.object(statsd_client, "_pending_queue") statsd_client.increment("test_closing") mocked_queue.assert_not_called() async def test_context_manager(self, unused_udp_port, statsd_server, wait_for): udp_server, collected = statsd_server async with aiodogstatsd.Client( host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"} ) as statsd_client: async with udp_server: statsd_client.gauge("test_gauge", value=42, tags={"and": "robin"}) await wait_for(collected) assert collected == [b"test_gauge:42|g|#whoami:batman,and:robin"] async def test_timeit(self, statsd_client, statsd_server, wait_for, mocker): udp_server, collected = statsd_server loop = mocker.patch("aiodogstatsd.client.get_event_loop") loop.return_value.time.return_value = 1.0 with statsd_client.timeit("test_timer", tags={"and": "robin"}): loop.return_value.time.return_value = 2.0 # This shouldn't be logged. loop.return_value.time.return_value = 1.0 with statsd_client.timeit( "test_timer", tags={"and": "robin"}, threshold_ms=3000.0 ): loop.return_value.time.return_value = 2.0 async with udp_server: await wait_for(collected) assert collected == [b"test_timer:1000|ms|#whoami:batman,and:robin"] async def test_timeit_task(self, statsd_client, statsd_server, wait_for, mocker): udp_server, collected = statsd_server async def do_nothing(): pass loop = mocker.patch("aiodogstatsd.client.get_event_loop") loop.return_value.create_task = asyncio.get_event_loop().create_task # Metric will be sent loop.return_value.time.return_value = 1.0 task = statsd_client.timeit_task( do_nothing(), "test_timer", tags={"and": "robin"}, threshold_ms=500 ) loop.return_value.time.return_value = 2.0 await task # Metric wont be sent because of not meeting the threshold loop.return_value.time.return_value = 1.0 task = statsd_client.timeit_task( do_nothing(), "test_timer", tags={"and": "robin"}, threshold_ms=1100 ) loop.return_value.time.return_value = 2.0 await task async with udp_server: await wait_for(collected) assert collected == [b"test_timer:1000|ms|#whoami:batman,and:robin"] aiodogstatsd-0.16.0/tests/test_contrib_aiohttp.py000066400000000000000000000146421415546532600222500ustar00rootroot00000000000000import asyncio import sys from http import HTTPStatus import aiohttp import pytest from aiohttp import web from yarl import URL from aiodogstatsd.contrib import aiohttp as aiodogstatsd pytestmark = pytest.mark.asyncio def all_tasks(): if sys.version_info >= (3, 7): return asyncio.all_tasks() else: tasks = list(asyncio.Task.all_tasks()) return {t for t in tasks if not t.done()} def current_task(): if sys.version_info >= (3, 7): return asyncio.current_task() else: return asyncio.Task.current_task() @pytest.fixture(autouse=True) async def aiohttp_server(unused_tcp_port, unused_udp_port): async def handler_hello(request): return web.json_response({"hello": "aiodogstatsd"}) async def handler_hello_variable(request): return web.json_response({"hello": request.match_info["name"]}) async def handler_bad_request(request): return web.json_response({"hello": "bad"}, status=HTTPStatus.BAD_REQUEST) async def handler_internal_server_error(request): raise NotImplementedError() async def handler_unauthorized(request): raise web.HTTPUnauthorized() app = web.Application(middlewares=[aiodogstatsd.middleware_factory()]) app.cleanup_ctx.append( aiodogstatsd.cleanup_context_factory( host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"} ) ) app.add_routes( [ web.get("/hello", handler_hello), web.get("/hello/{name}", handler_hello_variable), web.post("/bad_request", handler_bad_request), web.get("/internal_server_error", handler_internal_server_error), web.get("/unauthorized", handler_unauthorized), ] ) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, host="0.0.0.0", port=unused_tcp_port) await site.start() yield await runner.cleanup() @pytest.fixture def aiohttp_server_url(unused_tcp_port): return URL(f"http://0.0.0.0:{unused_tcp_port}") @pytest.fixture(autouse=True) def mock_loop_time(mocker): mock_loop = mocker.Mock() mock_loop.time.side_effect = [0, 1] mocker.patch("aiodogstatsd.contrib.aiohttp.get_event_loop", return_value=mock_loop) class TestAIOHTTP: async def test_ok(self, aiohttp_server_url, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with aiohttp.ClientSession() as session: async with session.get(aiohttp_server_url / "hello") as resp: assert resp.status == HTTPStatus.OK await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/hello,status:200" ] async def test_ok_variable_route(self, aiohttp_server_url, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with aiohttp.ClientSession() as session: async with session.get(aiohttp_server_url / "hello" / "batman") as resp: assert resp.status == HTTPStatus.OK await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/hello/{name},status:200" ] async def test_bad_request(self, aiohttp_server_url, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with aiohttp.ClientSession() as session: async with session.post(aiohttp_server_url / "bad_request") as resp: assert resp.status == HTTPStatus.BAD_REQUEST await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:POST,path:/bad_request,status:400" ] async def test_internal_server_error( self, aiohttp_server_url, statsd_server, wait_for ): udp_server, collected = statsd_server async with udp_server: async with aiohttp.ClientSession() as session: async with session.get( aiohttp_server_url / "internal_server_error" ) as resp: assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/internal_server_error,status:500" ] async def test_unauthorized(self, aiohttp_server_url, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with aiohttp.ClientSession() as session: async with session.get(aiohttp_server_url / "unauthorized") as resp: assert resp.status == HTTPStatus.UNAUTHORIZED await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/unauthorized,status:401" ] async def test_not_allowed(self, aiohttp_server_url, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with aiohttp.ClientSession() as session: async with session.post(aiohttp_server_url / "hello") as resp: assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED await wait_for(collected) assert collected == [] async def test_not_found(self, aiohttp_server_url, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with aiohttp.ClientSession() as session: async with session.get(aiohttp_server_url / "not_found") as resp: assert resp.status == HTTPStatus.NOT_FOUND await wait_for(collected) assert collected == [] @pytest.mark.timeout(10) async def test_client_closed_correctly(self): # Simulate actual behavior of the web.run_app clean up phase: # cancel all active tasks at the end # https://git.io/fj56P tasks = all_tasks() # cancel all tasks except current test_task = current_task() for task in tasks: if task is not test_task: task.cancel() # should not hang on the end aiodogstatsd-0.16.0/tests/test_contrib_starlette.py000066400000000000000000000130361415546532600226030ustar00rootroot00000000000000from http import HTTPStatus import pytest from async_asgi_testclient import TestClient from starlette.applications import Starlette from starlette.exceptions import HTTPException from starlette.middleware import Middleware from starlette.responses import JSONResponse from starlette.routing import Route import aiodogstatsd from aiodogstatsd.contrib.starlette import StatsDMiddleware pytestmark = pytest.mark.asyncio @pytest.fixture def starlette_application(unused_udp_port): async def handler_hello(request): return JSONResponse({"hello": "aiodogstatsd"}) async def handler_hello_variable(request): return JSONResponse({"hello": request.path_params["name"]}) async def handler_bad_request(request): return JSONResponse({"hello": "bad"}, status_code=HTTPStatus.BAD_REQUEST) async def handler_internal_server_error(request): raise NotImplementedError() async def handler_unauthorized(request): raise HTTPException(HTTPStatus.UNAUTHORIZED) client = aiodogstatsd.Client( host="0.0.0.0", port=unused_udp_port, constant_tags={"whoami": "batman"} ) return Starlette( debug=True, routes=[ Route("/hello", handler_hello), Route("/hello/{name}", handler_hello_variable), Route("/bad_request", handler_bad_request, methods=["POST"]), Route("/internal_server_error", handler_internal_server_error), Route("/unauthorized", handler_unauthorized), ], middleware=[Middleware(StatsDMiddleware, client=client)], on_startup=[client.connect], on_shutdown=[client.close], ) @pytest.fixture(autouse=True) def mock_loop_time(mocker): mock_loop = mocker.Mock() mock_loop.time.side_effect = [0, 1] mocker.patch( "aiodogstatsd.contrib.starlette.get_event_loop", return_value=mock_loop ) class TestStarlette: async def test_ok(self, starlette_application, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with TestClient(starlette_application) as client: resp = await client.get("/hello") assert resp.status_code == HTTPStatus.OK await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/hello,status:200" ] async def test_ok_variable_route( self, starlette_application, statsd_server, wait_for ): udp_server, collected = statsd_server async with udp_server: async with TestClient(starlette_application) as client: resp = await client.get("/hello/batman") assert resp.status_code == HTTPStatus.OK await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/hello/{name},status:200" ] async def test_bad_request(self, starlette_application, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with TestClient(starlette_application) as client: resp = await client.post("/bad_request") assert resp.status_code == HTTPStatus.BAD_REQUEST await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:POST,path:/bad_request,status:400" ] async def test_internal_server_error( self, starlette_application, statsd_server, wait_for ): udp_server, collected = statsd_server async with udp_server: async with TestClient(starlette_application) as client: # Here we can't check proper response status code due to realization of # test client. with pytest.raises(NotImplementedError): await client.get("/internal_server_error") await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/internal_server_error,status:500" ] async def test_unauthorized(self, starlette_application, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with TestClient(starlette_application) as client: resp = await client.get("/unauthorized") assert resp.status_code == HTTPStatus.UNAUTHORIZED await wait_for(collected) assert collected == [ b"http_request_duration:1000|ms" b"|#whoami:batman,method:GET,path:/unauthorized,status:401" ] async def test_not_allowed(self, starlette_application, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with TestClient(starlette_application) as client: resp = await client.post("/hello") assert resp.status_code == HTTPStatus.METHOD_NOT_ALLOWED await wait_for(collected) assert collected == [] async def test_not_found(self, starlette_application, statsd_server, wait_for): udp_server, collected = statsd_server async with udp_server: async with TestClient(starlette_application) as client: resp = await client.get("/not_found") assert resp.status_code == HTTPStatus.NOT_FOUND await wait_for(collected) assert collected == [] aiodogstatsd-0.16.0/tests/test_protocol.py000066400000000000000000000016151415546532600207150ustar00rootroot00000000000000import pytest from aiodogstatsd import protocol, typedefs @pytest.mark.parametrize( "in_, out", ( ( { "name": "name_1", "namespace": None, "value": "value_1", "type_": typedefs.MType.COUNTER, "tags": {}, "sample_rate": 1, }, b"name_1:value_1|c", ), ( { "name": "name_2", "namespace": "namespace_2", "value": "value_2", "type_": typedefs.MType.COUNTER, "tags": {"tag_key_1": "tag_value_1", "tag_key_2": "tag_value_2"}, "sample_rate": 0.5, }, b"namespace_2.name_2:value_2|c|@0.5|#tag_key_1:tag_value_1,tag_key_2:tag_value_2", ), ), ) def test_build(in_, out): assert out == protocol.build(**in_)