├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pylintrc ├── .readthedocs.yml ├── CHANGES.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── appveyor.yml ├── doc-requirements.txt ├── docs ├── _static │ └── custom.css ├── api.rst ├── changelog.rst ├── conf.py ├── index.rst └── testing.rst ├── pyproject.toml ├── scripts ├── install.ps1 ├── releases │ ├── geventrel.sh │ ├── geventreleases.sh │ └── make-manylinux └── run_with_env.cmd ├── setup.cfg ├── setup.py ├── src └── perfmetrics │ ├── __init__.py │ ├── _metric.pxd │ ├── _util.py │ ├── clientstack.py │ ├── interfaces.py │ ├── metric.py │ ├── pyramid.py │ ├── statsd.py │ ├── testing │ ├── __init__.py │ ├── client.py │ ├── matchers.py │ ├── observation.py │ └── tests │ │ ├── __init__.py │ │ ├── test_client.py │ │ ├── test_matchers.py │ │ └── test_observation.py │ ├── tests │ ├── __init__.py │ ├── bench_metric.py │ ├── benchmarks │ │ └── spraytest.py │ ├── test_clientstack.py │ ├── test_metric.py │ ├── test_perfmetrics.py │ ├── test_pyramid.py │ ├── test_statsd.py │ └── test_wsgi.py │ └── wsgi.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = perfmetrics 3 | omit = 4 | */bench_*.py 5 | 6 | # New in 5.0; required for the GHA coveralls submission. 7 | # Perhaps this obsoletes the source section in [paths]? 8 | # Probably not since we're now installing in non-editable mode. 9 | relative_files = True 10 | 11 | [report] 12 | # Coverage is run on Linux, so exclude branches that are windows, 13 | # specific 14 | exclude_lines = 15 | pragma: no cover 16 | def __repr__ 17 | raise AssertionError 18 | raise NotImplementedError 19 | raise Unsupported 20 | if __name__ == .__main__.: 21 | if sys.platform == 'win32': 22 | if mswindows: 23 | if is_windows: 24 | 25 | [paths] 26 | # Combine source and paths from the CI installs so they all get 27 | # collapsed during combining. Otherwise, coveralls.io reports 28 | # many different files (/lib/pythonX.Y/site-packages/gevent/...) and we don't 29 | # get a good aggregate number. 30 | source = 31 | src/ 32 | */lib/*/site-packages/ 33 | */pypy*/site-packages/ 34 | 35 | # Local Variables: 36 | # mode: conf 37 | # End: 38 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | ### 2 | # Initially copied from 3 | # https://github.com/actions/starter-workflows/blob/main/ci/python-package.yml 4 | # And later based on the version I (jamadden) updated at 5 | # gevent/gevent, and then at zodb/relstorage 6 | # 7 | # Original comment follows. 8 | ### 9 | ### 10 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 11 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 12 | ### 13 | 14 | ### 15 | # Important notes on GitHub actions: 16 | # 17 | # - We only get 2,000 free minutes a month (private repos) 18 | # - We only get 500MB of artifact storage 19 | # - Cache storage is limited to 7 days and 5GB. 20 | # - macOS minutes are 10x as expensive as Linux minutes 21 | # - windows minutes are twice as expensive. 22 | # 23 | # So keep those workflows light. 24 | # 25 | # In December 2020, github only supports x86/64. If we wanted to test 26 | # on other architectures, we can use docker emulation, but there's no 27 | # native support. 28 | # 29 | # Another major downside: You can't just re-run the job for one part 30 | # of the matrix. So if there's a transient test failure that hit, say, 3.8, 31 | # to get a clean run every version of Python runs again. That's bad. 32 | # https://github.community/t/ability-to-rerun-just-a-single-job-in-a-workflow/17234/65 33 | 34 | name: tests 35 | 36 | 37 | # Triggers the workflow on push or pull request events 38 | on: [push, pull_request] 39 | # Limiting to particular branches might be helpful to conserve minutes. 40 | #on: 41 | # push: 42 | # branches: [ $default-branch ] 43 | # pull_request: 44 | # branches: [ $default-branch ] 45 | 46 | env: 47 | # Weirdly, this has to be a top-level key, not ``defaults.env`` 48 | PYTHONHASHSEED: 8675309 49 | PYTHONUNBUFFERED: 1 50 | PYTHONDONTWRITEBYTECODE: 1 51 | # PYTHONDEVMODE leads to crashes in pylibmc. 52 | # See https://github.com/lericson/pylibmc/issues/254 53 | # - PYTHONDEVMODE=1 54 | PYTHONFAULTHANDLER: 1 55 | 56 | PIP_UPGRADE_STRATEGY: eager 57 | # Don't get warnings about Python 2 support being deprecated. We 58 | # know. The env var works for pip 20. 59 | PIP_NO_PYTHON_VERSION_WARNING: 1 60 | PIP_NO_WARN_SCRIPT_LOCATION: 1 61 | 62 | # Disable some warnings produced by libev especially and also some Cython generated code. 63 | # These are shared between GCC and clang so it must be a minimal set. 64 | # TODO: Figure out how to set env vars per platform without resorting to inline scripting. 65 | CFLAGS: -O3 -pipe 66 | CXXFLAGS: -O3 -pipe 67 | # Uploading built wheels for releases. 68 | # TWINE_PASSWORD is encrypted and stored directly in the 69 | # travis repo settings. 70 | TWINE_USERNAME: __token__ 71 | 72 | ### 73 | # caching 74 | # This is where we'd set up ccache, but this compiles so fast its not worth it. 75 | ### 76 | 77 | 78 | 79 | jobs: 80 | test: 81 | runs-on: ${{ matrix.os }} 82 | strategy: 83 | matrix: 84 | python-version: [pypy-3.10, 3.8, 3.9, '3.10', '3.11', "3.12", "3.13.0-beta.1"] 85 | os: [ubuntu-latest, macos-latest] 86 | exclude: 87 | - os: macos-latest 88 | python-version: pypy-3.10 89 | - os: macos-latest 90 | python-version: "3.8" 91 | - os: macos-latest 92 | python-version: "3.9" 93 | steps: 94 | - name: checkout 95 | uses: actions/checkout@v4 96 | 97 | - name: Set up Python ${{ matrix.python-version }} 98 | uses: actions/setup-python@v5 99 | with: 100 | python-version: ${{ matrix.python-version }} 101 | cache: 'pip' 102 | cache-dependency-path: setup.py 103 | 104 | - name: Install Build Dependencies 105 | run: | 106 | pip install -U pip 107 | pip install -U setuptools wheel twine build 108 | pip install -U 'cython>=3.0.10' 109 | pip install -U coverage 110 | - name: Install perfmetrics (non-Mac) 111 | if: ${{ ! startsWith(runner.os, 'Mac') }} 112 | run: | 113 | python -m build 114 | python -m pip install -U -e ".[test,docs]" 115 | env: 116 | # Ensure we test with assertions enabled. 117 | # As opposed to the manylinux builds, which we distribute and 118 | # thus only use O3 (because Ofast enables fast-math, which has 119 | # process-wide effects), we test with Ofast here, because we 120 | # expect that some people will compile it themselves with that setting. 121 | CPPFLAGS: "-Ofast -UNDEBUG" 122 | 123 | - name: Install perfmetrics (Mac) 124 | if: startsWith(runner.os, 'Mac') 125 | run: | 126 | python -m build 127 | python -m pip install -U -e ".[test,docs]" 128 | env: 129 | # Unlike the above, we are actually distributing these 130 | # wheels, so they need to be built for production use. 131 | CPPFLAGS: "-O3" 132 | # Build for both architectures 133 | ARCHFLAGS: "-arch x86_64 -arch arm64" 134 | 135 | - name: Check perfmetrics build 136 | run: | 137 | ls -l dist 138 | twine check dist/* 139 | 140 | - name: Upload perfmetrics wheel 141 | uses: actions/upload-artifact@v3 142 | with: 143 | name: perfmetrics-${{ runner.os }}-${{ matrix.python-version }}.whl 144 | path: dist/*whl 145 | 146 | - name: Run tests and report coverage 147 | run: | 148 | coverage run -p -m zope.testrunner --path=src --package perfmetrics -v --color 149 | PURE_PYTHON=1 coverage run -p -m zope.testrunner --path=src --package perfmetrics -v --color 150 | coverage combine 151 | coverage report -i 152 | 153 | - name: Publish package to PyPI (mac) 154 | # We cannot 'uses: pypa/gh-action-pypi-publish@v1.4.1' because 155 | # that's apparently a container action, and those don't run on 156 | # the Mac. 157 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') && startsWith(runner.os, 'Mac') 158 | env: 159 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 160 | run: | 161 | twine upload --skip-existing dist/* 162 | 163 | - name: lint 164 | if: matrix.python-version == '3.11' && startsWith(runner.os, 'Linux') 165 | # At this writing, PyLint 2.17/astroid 2.15 won't work on 3.12 166 | run: | 167 | pip install -U pylint 168 | python -m pylint --limit-inference-results=1 --rcfile=.pylintrc perfmetrics -f parseable -r n 169 | 170 | manylinux: 171 | runs-on: ubuntu-latest 172 | # We use a regular Python matrix entry to share as much code as possible. 173 | strategy: 174 | matrix: 175 | python-version: [3.11] 176 | image: 177 | - manylinux2014_aarch64 178 | - manylinux2014_ppc64le 179 | - manylinux2014_s390x 180 | - manylinux2014_x86_64 181 | - musllinux_1_1_x86_64 182 | - musllinux_1_1_aarch64 183 | name: ${{ matrix.image }} 184 | steps: 185 | - name: checkout 186 | uses: actions/checkout@v4 187 | - name: Set up Python ${{ matrix.python-version }} 188 | uses: actions/setup-python@v5 189 | with: 190 | python-version: ${{ matrix.python-version }} 191 | - name: Set up QEMU 192 | uses: docker/setup-qemu-action@v2 193 | with: 194 | platforms: all 195 | - name: Build and test perfmetrics 196 | env: 197 | DOCKER_IMAGE: quay.io/pypa/${{ matrix.image }} 198 | run: bash ./scripts/releases/make-manylinux 199 | - name: Store perfmetrics wheels 200 | uses: actions/upload-artifact@v3 201 | with: 202 | path: wheelhouse/*whl 203 | name: ${{ matrix.image }}_wheels.zip 204 | - name: Publish package to PyPI 205 | uses: pypa/gh-action-pypi-publish@v1.4.1 206 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 207 | with: 208 | user: __token__ 209 | password: ${{ secrets.TWINE_PASSWORD }} 210 | skip_existing: true 211 | packages_dir: wheelhouse/ 212 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | *.so 4 | .coverage 5 | .installed.cfg 6 | .pydevproject 7 | bin 8 | build 9 | coverage 10 | develop-eggs 11 | dist 12 | eggs 13 | include 14 | lib 15 | local 16 | man 17 | parts 18 | docs/_build 19 | htmlcov/ 20 | src/perfmetrics/metric.c 21 | src/perfmetrics/metric.html 22 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | #[MASTER] 2 | # These need examined and fixed on a case-by-case basis. 3 | #load-plugins=pylint.extensions.bad_builtin,pylint.extensions.check_elif,pylint.extensions.comparetozero,pylint.extensions.emptystring 4 | 5 | [MESSAGES CONTROL] 6 | 7 | # Disable the message, report, category or checker with the given id(s). You 8 | # can either give multiple identifier separated by comma (,) or put this option 9 | # multiple time (only on the command line, not in the configuration file where 10 | # it should appear only once). 11 | # NOTE: comments must go ABOVE the statement. In Python 2, mixing in 12 | # comments disables all directives that follow, while in Python 3, putting 13 | # comments at the end of the line does the same thing (though Py3 supports 14 | # mixing) 15 | 16 | # invalid-name, ; Things like loadBlob get flagged 17 | # protected-access, ; We have many cases of this; legit ones need to be examinid and commented, then this removed 18 | # no-self-use, ; common in superclasses with extension points 19 | # too-few-public-methods, ; Exception and marker classes get tagged with this 20 | # exec-used, ; should tag individual instances with this, there are some but not too many 21 | # global-statement, ; should tag individual instances 22 | # multiple-statements, ; "from gevent import monkey; monkey.patch_all()" 23 | # locally-disabled, ; yes, we know we're doing this. don't replace one warning with another 24 | # cyclic-import, ; most of these are deferred imports 25 | # too-many-arguments, ; these are almost always because that's what the stdlib does 26 | # redefined-builtin, ; likewise: these tend to be keyword arguments like len= in the stdlib 27 | # undefined-all-variable, ; XXX: This crashes with pylint 1.5.4 on Travis (but not locally on Py2/3 28 | # ; or landscape.io on Py3). The file causing the problem is unclear. UPDATE: identified and disabled 29 | # that file. 30 | # see https://github.com/PyCQA/pylint/issues/846 31 | # useless-suppression: the only way to avoid repeating it for specific statements everywhere that we 32 | # do Py2/Py3 stuff is to put it here. Sadly this means that we might get better but not realize it. 33 | # chained-comparison: It wants you to rewrite `x > 2 and x < 3` using something like `2 < x < 3`, 34 | # which I don't like, I find that less readable. 35 | disable= 36 | invalid-name, 37 | missing-docstring, 38 | ungrouped-imports, 39 | protected-access, 40 | too-few-public-methods, 41 | exec-used, 42 | global-statement, 43 | multiple-statements, 44 | locally-disabled, 45 | cyclic-import, 46 | too-many-arguments, 47 | redefined-builtin, 48 | useless-suppression, 49 | duplicate-code, 50 | inconsistent-return-statements, 51 | useless-object-inheritance, 52 | chained-comparison, 53 | too-many-ancestors, 54 | super-with-arguments, 55 | import-outside-toplevel, 56 | consider-using-f-string 57 | # undefined-all-variable 58 | 59 | 60 | [IMPORTS] 61 | # It's common to have ZODB installed in the virtual environment 62 | # with us. That causes it to be recognized as first party, meaning 63 | # that it is suddenly sorted incorrectly compared to other third party 64 | # imports such as zope. 65 | known-third-party=ZODB 66 | 67 | [FORMAT] 68 | # duplicated from setup.cfg 69 | max-line-length=100 70 | 71 | [MISCELLANEOUS] 72 | # List of note tags to take in consideration, separated by a comma. 73 | #notes=FIXME,XXX,TODO 74 | # Disable that, we don't want them in the report (???) 75 | notes= 76 | 77 | [VARIABLES] 78 | 79 | dummy-variables-rgx=_.* 80 | 81 | [TYPECHECK] 82 | 83 | # List of members which are set dynamically and missed by pylint inference 84 | # system, and so shouldn't trigger E1101 when accessed. Python regular 85 | # expressions are accepted. 86 | # gevent: this is helpful for py3/py2 code. 87 | generated-members=exc_clear 88 | 89 | # List of classes names for which member attributes should not be checked 90 | # (useful for classes with attributes dynamically set). This supports can work 91 | # with qualified names. 92 | 93 | ignored-classes=SectionValue 94 | 95 | # List of module names for which member attributes should not be checked 96 | # (useful for modules/projects where namespaces are manipulated during runtime 97 | # and thus existing member attributes cannot be deduced by static analysis. It 98 | # supports qualified module names, as well as Unix pattern matching. 99 | ignored-modules=relstorage.cache._cache_ring 100 | 101 | [DESIGN] 102 | max-attributes=15 103 | max-locals=20 104 | 105 | 106 | [BASIC] 107 | # Prospector turns on unsafe-load-any-extension by default, but 108 | # pylint leaves it off. This is the proximal cause of the 109 | # undefined-all-variable crash. 110 | #unsafe-load-any-extension = no 111 | 112 | # Local Variables: 113 | # mode: conf-space 114 | # End: 115 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Some things can only be configured on the RTD dashboard. 6 | # Those that we may have changed from the default include: 7 | 8 | # Analytics code: 9 | # Show Version Warning: False 10 | # Single Version: True 11 | 12 | # Required 13 | version: 2 14 | 15 | # Build documentation in the docs/ directory with Sphinx 16 | sphinx: 17 | builder: html 18 | configuration: docs/conf.py 19 | 20 | # Set the version of Python and other tools you might need 21 | build: 22 | os: ubuntu-22.04 23 | tools: 24 | python: "3.11" 25 | # You can also specify other tool versions: 26 | # nodejs: "19" 27 | # rust: "1.64" 28 | # golang: "1.19" 29 | 30 | # Set the version of Python and requirements required to build your 31 | # docs 32 | python: 33 | install: 34 | - method: pip 35 | path: . 36 | extra_requirements: 37 | - docs 38 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | CHANGES 3 | ========= 4 | 5 | 4.1.1 (unreleased) 6 | ================== 7 | 8 | - Nothing changed yet. 9 | 10 | 11 | 4.1.0 (2024-06-11) 12 | ================== 13 | 14 | - Add support for Python 3.13. 15 | - Drop support for Python 3.7. 16 | - Drop support for Manylinux 2010 wheels. 17 | 18 | 4.0.0 (2023-06-22) 19 | ================== 20 | 21 | - Drop support for obsolete Python versions, including Python 2.7 and 22 | 3.6. 23 | - Add support for Python 3.12. 24 | 25 | 26 | 3.3.0 (2022-09-25) 27 | ================== 28 | 29 | - Stop accidentally building manylinux wheels with unsafe math 30 | optimizations. 31 | - Add support for Python 3.11. 32 | 33 | NOTE: This will be the last major release to support legacy versions 34 | of Python such as 2.7 and 3.6. Some such legacy versions may not have 35 | binary wheels published for this release. 36 | 37 | 38 | 3.2.0.post0 (2021-09-28) 39 | ======================== 40 | 41 | - Add Windows wheels for 3.9 and 3.10. 42 | 43 | 44 | 3.2.0 (2021-09-28) 45 | ================== 46 | 47 | - Add support for Python 3.10. 48 | 49 | - Drop support for Python 3.5. 50 | 51 | - Add aarch64 binary wheels. 52 | 53 | 3.1.0 (2021-02-04) 54 | ================== 55 | 56 | - Add support for Python 3.8 and 3.9. 57 | - Move to GitHub Actions from Travis CI. 58 | - Support PyHamcrest 1.10 and later. See `issue 26 59 | `_. 60 | - The ``FakeStatsDClient`` for testing is now always true whether or 61 | not any observations have been seen, like the normal clients. See 62 | `issue `_. 63 | - Add support for `StatsD sets 64 | `_, 65 | counters of unique events. See `PR 30 `_. 66 | 67 | 3.0.0 (2019-09-03) 68 | ================== 69 | 70 | - Drop support for EOL Python 2.6, 3.2, 3.3 and 3.4. 71 | 72 | - Add support for Python 3.5, 3.6, and 3.7. 73 | 74 | - Compile the performance-sensitive parts with Cython, leading to a 75 | 10-30% speed improvement. See 76 | https://github.com/zodb/perfmetrics/issues/17. 77 | 78 | - Caution: Metric names are enforced to be native strings (as a result 79 | of Cython compilation); they've always had to be ASCII-only but 80 | previously Unicode was allowed on Python 2. This is usually 81 | automatically the case when used as a decorator. On Python 2 using 82 | ``from __future__ import unicode_literals`` can cause problems 83 | (raising TypeError) when manually constructing ``Metric`` objects. A 84 | quick workaround is to set the environment variable 85 | ``PERFMETRICS_PURE_PYTHON`` before importing perfmetrics. 86 | 87 | - Make decorated functions and methods configurable at runtime, not 88 | just compile time. See 89 | https://github.com/zodb/perfmetrics/issues/11. 90 | 91 | - Include support for testing applications instrumented with 92 | perfmetrics in ``perfmetrics.testing``. This was previously released 93 | externally as ``nti.fakestatsd``. See https://github.com/zodb/perfmetrics/issues/9. 94 | 95 | - Read the ``PERFMETRICS_DISABLE_DECORATOR`` environment variable when 96 | ``perfmetrics`` is imported, and if it is set, make the decorators ``@metric``, 97 | ``@metricmethod``, ``@Metric(...)`` and ``@MetricMod(...)`` return 98 | the function unchanged. This can be helpful for certain kinds of 99 | introspection tests. See https://github.com/zodb/perfmetrics/issues/15 100 | 101 | 2.0 (2013-12-10) 102 | ================ 103 | 104 | - Added the ``@MetricMod`` decorator, which changes the name of 105 | metrics in a given context. For example, ``@MetricMod('xyz.%s')`` 106 | adds a prefix. 107 | 108 | - Removed the "gauge suffix" feature. It was unnecessarily confusing. 109 | 110 | - Timing metrics produced by ``@metric``, ``@metricmethod``, and 111 | ``@Metric`` now have a ".t" suffix by default to avoid naming 112 | conflicts. 113 | 114 | 1.0 (2012-10-09) 115 | ================ 116 | 117 | - Added 'perfmetrics.tween' and 'perfmetrics.wsgi' stats for measuring 118 | request timing and counts. 119 | 120 | 0.9.5 (2012-09-22) 121 | ================== 122 | 123 | - Added an optional Pyramid tween and a similar WSGI filter app 124 | that sets up the Statsd client for each request. 125 | 126 | 0.9.4 (2012-09-08) 127 | ================== 128 | 129 | - Optimized the use of reduced sample rates. 130 | 131 | 0.9.3 (2012-09-08) 132 | ================== 133 | 134 | - Support the ``STATSD_URI`` environment variable. 135 | 136 | 0.9.2 (2012-09-01) 137 | ================== 138 | 139 | - ``Metric`` can now be used as either a decorator or a context 140 | manager. 141 | 142 | - Made the signature of StatsdClient more like James Socol's 143 | StatsClient. 144 | 145 | 0.9.1 (2012-09-01) 146 | ================== 147 | 148 | - Fixed package metadata. 149 | 150 | 0.9 (2012-08-31) 151 | ================ 152 | 153 | - Initial release. 154 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | License 2 | 3 | A copyright notice accompanies this license document that identifies 4 | the copyright holders. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | 1. Redistributions in source code must retain the accompanying 11 | copyright notice, this list of conditions, and the following 12 | disclaimer. 13 | 14 | 2. Redistributions in binary form must reproduce the accompanying 15 | copyright notice, this list of conditions, and the following 16 | disclaimer in the documentation and/or other materials provided 17 | with the distribution. 18 | 19 | 3. Names of the copyright holders must not be used to endorse or 20 | promote products derived from this software without prior 21 | written permission from the copyright holders. 22 | 23 | 4. If any files are modified, you must cause the modified files to 24 | carry prominent notices stating that you changed the files and 25 | the date of any change. 26 | 27 | Disclaimer 28 | 29 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND 30 | ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 31 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 32 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 33 | HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 34 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 35 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 36 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 37 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 38 | TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 39 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 40 | SUCH DAMAGE. 41 | 42 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.in 2 | include *.rst 3 | include AUTHORS 4 | include LICENSE.txt 5 | include setup.cfg 6 | include nose2.cfg 7 | include tox.ini 8 | include .travis.yml 9 | include .coveragerc 10 | include .isort.cfg 11 | include .pylintrc 12 | include *.toml 13 | include *.yml 14 | 15 | recursive-include src *.pxd 16 | recursive-include src *.py 17 | recursive-include src *.c 18 | recursive-exclude src *.html 19 | recursive-include src *.po 20 | recursive-include src *.pot 21 | recursive-include src *.zcml 22 | include *.txt 23 | 24 | recursive-include docs *.py 25 | recursive-include docs *.rst 26 | recursive-include docs Makefile 27 | recursive-include docs *.css 28 | 29 | recursive-include scripts/releases * 30 | recursive-include scripts *.cmd 31 | recursive-include scripts *.ps1 32 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | perfmetrics 3 | ============= 4 | 5 | The perfmetrics package provides a simple way to add software performance 6 | metrics to Python libraries and applications. Use perfmetrics to find the 7 | true bottlenecks in a production application. 8 | 9 | The perfmetrics package is a client of the Statsd daemon by Etsy, which 10 | is in turn a client of Graphite (specifically, the Carbon daemon). Because 11 | the perfmetrics package sends UDP packets to Statsd, perfmetrics adds 12 | no I/O delays to applications and little CPU overhead. It can work 13 | equally well in threaded (synchronous) or event-driven (asynchronous) 14 | software. 15 | 16 | Complete documentation is hosted at https://perfmetrics.readthedocs.io 17 | 18 | .. image:: https://img.shields.io/pypi/v/perfmetrics.svg 19 | :target: https://pypi.org/project/perfmetrics/ 20 | :alt: Latest release 21 | 22 | .. image:: https://img.shields.io/pypi/pyversions/perfmetrics.svg 23 | :target: https://pypi.org/project/perfmetrics/ 24 | :alt: Supported Python versions 25 | 26 | .. image:: https://github.com/zodb/perfmetrics/workflows/tests/badge.svg 27 | :target: https://github.com/zodb/perfmetrics/actions?query=workflow%3Atests 28 | :alt: CI Build Status 29 | 30 | .. image:: https://coveralls.io/repos/github/zodb/perfmetrics/badge.svg 31 | :target: https://coveralls.io/github/zodb/perfmetrics 32 | :alt: Code Coverage 33 | 34 | .. image:: https://readthedocs.org/projects/perfmetrics/badge/?version=latest 35 | :target: https://perfmetrics.readthedocs.io/en/latest/?badge=latest 36 | :alt: Documentation Status 37 | 38 | Usage 39 | ===== 40 | 41 | Use the ``@metric`` and ``@metricmethod`` decorators to wrap functions 42 | and methods that should send timing and call statistics to Statsd. 43 | Add the decorators to any function or method that could be a bottleneck, 44 | including library functions. 45 | 46 | .. caution:: 47 | 48 | These decorators are generic and cause the actual function 49 | signature to be lost, replaced with ``*args, **kwargs``. This can 50 | break certain types of introspection, including `zope.interface 51 | validation `_. As a 52 | workaround, setting the environment variable 53 | ``PERFMETRICS_DISABLE_DECORATOR`` *before* importing perfmetrics or 54 | code that uses it will cause ``@perfmetrics.metric``, ``@perfmetrics.metricmethod``, 55 | ``@perfmetrics.Metric(...)`` and ``@perfmetrics.MetricMod(...)`` to 56 | return the original function unchanged. 57 | 58 | Sample:: 59 | 60 | from perfmetrics import metric 61 | from perfmetrics import metricmethod 62 | 63 | @metric 64 | def myfunction(): 65 | """Do something that might be expensive""" 66 | 67 | class MyClass(object): 68 | @metricmethod 69 | def mymethod(self): 70 | """Do some other possibly expensive thing""" 71 | 72 | Next, tell perfmetrics how to connect to Statsd. (Until you do, the 73 | decorators have no effect.) Ideally, either your application should read the 74 | Statsd URI from a configuration file at startup time, or you should set 75 | the STATSD_URI environment variable. The example below uses a 76 | hard-coded URI:: 77 | 78 | from perfmetrics import set_statsd_client 79 | set_statsd_client('statsd://localhost:8125') 80 | 81 | for i in xrange(1000): 82 | myfunction() 83 | MyClass().mymethod() 84 | 85 | If you run that code, it will fire 2000 UDP packets at port 86 | 8125. However, unless you have already installed Graphite and Statsd, 87 | all of those packets will be ignored and dropped. Dropping is a good thing: 88 | you don't want your production application to fail or slow down just 89 | because your performance monitoring system is stopped or not working. 90 | 91 | Install Graphite and Statsd to receive and graph the metrics. One good way 92 | to install them is the `graphite_buildout example`_ at github, which 93 | installs Graphite and Statsd in a custom location without root access. 94 | 95 | .. _`graphite_buildout example`: https://github.com/hathawsh/graphite_buildout 96 | 97 | Pyramid and WSGI 98 | ================ 99 | 100 | If you have a Pyramid app, you can set the ``statsd_uri`` for each 101 | request by including perfmetrics in your configuration:: 102 | 103 | config = Configuration(...) 104 | config.include('perfmetrics') 105 | 106 | Also add a ``statsd_uri`` setting such as ``statsd://localhost:8125``. 107 | Once configured, the perfmetrics tween will set up a Statsd client for 108 | the duration of each request. This is especially useful if you run 109 | multiple apps in one Python interpreter and you want a different 110 | ``statsd_uri`` for each app. 111 | 112 | Similar functionality exists for WSGI apps. Add the app to your Paste Deploy 113 | pipeline:: 114 | 115 | [statsd] 116 | use = egg:perfmetrics#statsd 117 | statsd_uri = statsd://localhost:8125 118 | 119 | [pipeline:main] 120 | pipeline = 121 | statsd 122 | egg:myapp#myentrypoint 123 | 124 | Threading 125 | ========= 126 | 127 | While most programs send metrics from any thread to a single global 128 | Statsd server, some programs need to use a different Statsd server 129 | for each thread. If you only need a global Statsd server, use the 130 | ``set_statsd_client`` function at application startup. If you need 131 | to use a different Statsd server for each thread, use the 132 | ``statsd_client_stack`` object in each thread. Use the 133 | ``push``, ``pop``, and ``clear`` methods. 134 | 135 | 136 | Graphite Tips 137 | ============= 138 | 139 | Graphite stores each metric as a time series with multiple 140 | resolutions. The sample graphite_buildout stores 10 second resolution 141 | for 48 hours, 1 hour resolution for 31 days, and 1 day resolution for 5 years. 142 | To produce a coarse grained value from a fine grained value, Graphite computes 143 | the mean value (average) for each time span. 144 | 145 | Because Graphite computes mean values implicitly, the most sensible way to 146 | treat counters in Graphite is as a "hits per second" value. That way, 147 | a graph can produce correct results no matter which resolution level 148 | it uses. 149 | 150 | Treating counters as hits per second has unfortunate consequences, however. 151 | If some metric sees a 1000 hit spike in one second, then falls to zero for 152 | at least 9 seconds, the Graphite chart for that metric will show a spike 153 | of 100, not 1000, since Graphite receives metrics every 10 seconds and the 154 | spike looks to Graphite like 100 hits per second over a 10 second period. 155 | 156 | If you want your graph to show 1000 hits rather than 100 hits per second, 157 | apply the Graphite ``hitcount()`` function, using a resolution of 158 | 10 seconds or more. The hitcount function converts per-second 159 | values to approximate raw hit counts. Be sure 160 | to provide a resolution value large enough to be represented by at least 161 | one pixel width on the resulting graph, otherwise Graphite will compute 162 | averages of hit counts and produce a confusing graph. 163 | 164 | It usually makes sense to treat null values in Graphite as zero, though 165 | that is not the default; by default, Graphite draws nothing for null values. 166 | You can turn on that option for each graph. 167 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | clone_depth: 50 2 | max_jobs: 8 3 | shallow_clone: true 4 | build: 5 | parallel: true 6 | verbosity: minimal 7 | # The VS 2019 image doesn't have 8 | # the MSVC needed for Python 2.7. 9 | # Note that this contains nothing newer than Python 3.8; there is no 10 | # image that has all of our supported versions, so we have to 11 | # customize per version. 12 | image: Visual Studio 2015 13 | 14 | environment: 15 | global: 16 | APPVEYOR_SAVE_CACHE_ON_ERROR: "true" 17 | # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the 18 | # /E:ON and /V:ON options are not enabled in the batch script interpreter 19 | # See: http://stackoverflow.com/a/13751649/163740 20 | CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\scripts\\run_with_env.cmd" 21 | # Use a fixed hash seed for reproducability 22 | PYTHONHASHSEED: 8675309 23 | ZOPE_INTERFACE_STRICT_IRO: 1 24 | # Don't get warnings about Python 2 support being deprecated. We 25 | # know. 26 | PIP_NO_PYTHON_VERSION_WARNING: 1 27 | PIP_UPGRADE_STRATEGY: eager 28 | # Enable this if debugging a resource leak. Otherwise 29 | # it slows things down. 30 | # PYTHONTRACEMALLOC: 10 31 | ## 32 | # Upload settings for twine. 33 | TWINE_USERNAME: __token__ 34 | TWINE_PASSWORD: 35 | secure: uXZ6Juhz2hElaTsaJ2HnetZqz0mmNO3phE2IV3Am7hgfOAbaM4x3IeNSS7bMWL27TMGsOndOrKNgQTodirUt+vLZzZ+NYKjMImuM04P68BfIGDeZlA8ynYWG0vtjpqUTfrbhppyLuypHmzusV7+cnlSq4uaE3BtZ+bSwUZUYaeEQRAnCivzLki318kzOCLUUjDuyPSgyTdV+Z4GXOtUzGInvsbiU7k+9PbpE10915afTg82GUHHYn9BC5laBvxI1A07HX/JJZ6QjwS9+KjmEtw== 36 | 37 | 38 | matrix: 39 | # http://www.appveyor.com/docs/installed-software#python 40 | 41 | # Fully supported 64-bit versions, with testing. This should be 42 | # all the current (non EOL) versions. 43 | - PYTHON: "C:\\Python312-x64" 44 | PYTHON_VERSION: "3.12.0b3" 45 | PYTHON_ARCH: "64" 46 | PYTHON_EXE: python 47 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 48 | 49 | - PYTHON: "C:\\Python311-x64" 50 | PYTHON_VERSION: "3.11.0" 51 | PYTHON_ARCH: "64" 52 | PYTHON_EXE: python 53 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 54 | 55 | - PYTHON: "C:\\Python310-x64" 56 | PYTHON_VERSION: "3.10.0" 57 | PYTHON_ARCH: "64" 58 | PYTHON_EXE: python 59 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 60 | 61 | - PYTHON: "C:\\Python39-x64" 62 | PYTHON_VERSION: "3.9.x" 63 | PYTHON_ARCH: "64" 64 | PYTHON_EXE: python 65 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 66 | 67 | - PYTHON: "C:\\Python38-x64" 68 | PYTHON_VERSION: "3.8.x" 69 | PYTHON_ARCH: "64" 70 | PYTHON_EXE: python 71 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 72 | 73 | - PYTHON: "C:\\Python37-x64" 74 | PYTHON_VERSION: "3.7.x" 75 | PYTHON_ARCH: "64" 76 | PYTHON_EXE: python 77 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 78 | 79 | # 32-bit, wheel only (no testing) 80 | - PYTHON: "C:\\Python39" 81 | PYTHON_VERSION: "3.9.x" 82 | PYTHON_ARCH: "32" 83 | PYTHON_EXE: python 84 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 85 | 86 | - PYTHON: "C:\\Python38" 87 | PYTHON_VERSION: "3.8.x" 88 | PYTHON_ARCH: "32" 89 | PYTHON_EXE: python 90 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 91 | 92 | - PYTHON: "C:\\Python37" 93 | PYTHON_VERSION: "3.7.x" 94 | PYTHON_ARCH: "32" 95 | PYTHON_EXE: python 96 | APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2019 97 | 98 | 99 | install: 100 | - ECHO "Filesystem root:" 101 | - ps: "ls \"C:/\"" 102 | 103 | - ECHO "Installed SDKs:" 104 | - ps: "if(Test-Path(\"C:/Program Files/Microsoft SDKs/Windows\")) {ls \"C:/Program Files/Microsoft SDKs/Windows\";}" 105 | 106 | # Install Python (from the official .msi of http://python.org) and pip when 107 | # not already installed. 108 | # PyPy portion based on https://github.com/wbond/asn1crypto/blob/master/appveyor.yml 109 | - ps: 110 | $env:PYTMP = "${env:TMP}\py"; 111 | if (!(Test-Path "$env:PYTMP")) { 112 | New-Item -ItemType directory -Path "$env:PYTMP" | Out-Null; 113 | } 114 | if ("${env:PYTHON_ID}" -eq "pypy") { 115 | if (!(Test-Path "${env:PYTMP}\pypy2-v7.3.1-win32.zip")) { 116 | (New-Object Net.WebClient).DownloadFile('https://bitbucket.org/pypy/pypy/downloads/pypy2.7-v7.3.1-win32.zip', "${env:PYTMP}\pypy2-v7.3.1-win32.zip"); 117 | } 118 | 7z x -y "${env:PYTMP}\pypy2-v7.3.1-win32.zip" -oC:\ | Out-Null; 119 | } 120 | elseif (-not(Test-Path($env:PYTHON))) { 121 | & scripts\install.ps1; 122 | } 123 | 124 | # Prepend newly installed Python to the PATH of this build (this cannot be 125 | # done from inside the powershell script as it would require to restart 126 | # the parent CMD process). 127 | - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PYTHON%\\bin;%PATH%" 128 | - "SET PYEXE=%PYTHON%\\%PYTHON_EXE%.exe" 129 | 130 | # Check that we have the expected version and architecture for Python 131 | - "%PYEXE% --version" 132 | - "%PYEXE% -c \"import struct; print(struct.calcsize('P') * 8)\"" 133 | 134 | # Upgrade to the latest version of pip to avoid it displaying warnings 135 | # about it being out of date. Do this here instead of above in 136 | # powershell because the annoying 'DEPRECATION:blahblahblah 2.7 blahblahblah' 137 | # breaks powershell. 138 | - "%CMD_IN_ENV% %PYEXE% -mensurepip -U --user" 139 | - "%CMD_IN_ENV% %PYEXE% -mpip install -U --user pip" 140 | 141 | - ps: "if(Test-Path(\"${env:PYTHON}\\bin\")) {ls ${env:PYTHON}\\bin;}" 142 | - ps: "if(Test-Path(\"${env:PYTHON}\\Scripts\")) {ls ${env:PYTHON}\\Scripts;}" 143 | 144 | build_script: 145 | # Build the compiled extension 146 | # Try to get some things that don't wind up in the pip cache as 147 | # built wheels if they're built during an isolated build. 148 | - "%CMD_IN_ENV% %PYEXE% -m pip install --pre -U wheel cython setuptools" 149 | - "%PYEXE% -m pip install --pre -U -e .[test]" 150 | - "%PYEXE% -W ignore setup.py -q bdist_wheel" 151 | 152 | test_script: 153 | - python -m zope.testrunner --test-path=src 154 | 155 | artifacts: 156 | - path: 'dist\*.whl' 157 | name: wheel 158 | 159 | deploy_script: 160 | - ps: if ($env:APPVEYOR_REPO_TAG -eq $TRUE) { pip install twine; twine upload --skip-existing dist/* } 161 | 162 | deploy: on 163 | 164 | cache: 165 | - "%TMP%\\py\\" 166 | - '%LOCALAPPDATA%\pip\Cache -> appveyor.yml,setup.py' 167 | -------------------------------------------------------------------------------- /doc-requirements.txt: -------------------------------------------------------------------------------- 1 | .[docs] 2 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* Font definitions */ 2 | @font-face { 3 | font-family: 'JetBrains Mono'; 4 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Bold-Italic.woff2') format('woff2'), 5 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Bold-Italic.woff') format('woff'); 6 | font-weight: 700; 7 | font-style: italic; 8 | font-display: swap; 9 | } 10 | 11 | @font-face { 12 | font-family: 'JetBrains Mono'; 13 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Bold.woff2') format('woff2'), 14 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Bold.woff') format('woff'); 15 | font-weight: 700; 16 | font-style: normal; 17 | font-display: swap; 18 | } 19 | 20 | @font-face { 21 | font-family: 'JetBrains Mono'; 22 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-ExtraBold-Italic.woff2') format('woff2'), 23 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-ExtraBold-Italic.woff') format('woff'); 24 | font-weight: 800; 25 | font-style: italic; 26 | font-display: swap; 27 | } 28 | 29 | @font-face { 30 | font-family: 'JetBrains Mono'; 31 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-ExtraBold.woff2') format('woff2'), 32 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-ExtraBold.woff') format('woff'); 33 | font-weight: 800; 34 | font-style: normal; 35 | font-display: swap; 36 | } 37 | 38 | @font-face { 39 | font-family: 'JetBrains Mono'; 40 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Italic.woff2') format('woff2'), 41 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Italic.woff') format('woff'); 42 | font-weight: 400; 43 | font-style: italic; 44 | font-display: swap; 45 | } 46 | 47 | @font-face { 48 | font-family: 'JetBrains Mono'; 49 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Medium-Italic.woff2') format('woff2'), 50 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Medium-Italic.woff') format('woff'); 51 | font-weight: 500; 52 | font-style: italic; 53 | font-display: swap; 54 | } 55 | 56 | @font-face { 57 | font-family: 'JetBrains Mono'; 58 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Medium.woff2') format('woff2'), 59 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Medium.woff') format('woff'); 60 | font-weight: 500; 61 | font-style: normal; 62 | font-display: swap; 63 | } 64 | 65 | @font-face { 66 | font-family: 'JetBrains Mono'; 67 | src: url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Regular.woff2') format('woff2'), 68 | url('https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff/JetBrainsMono-Regular.woff') format('woff'); 69 | font-weight: 400; 70 | font-style: normal; 71 | font-display: swap; 72 | } 73 | 74 | 75 | article { 76 | /* Furo theme makes this 1.5 which uses soo much space */ 77 | line-height: 1.1; 78 | } 79 | 80 | .admonition-opinion p.admonition-title { 81 | background-color: rgba(255, 150, 235, 0.44); 82 | } 83 | 84 | div.admonition-opinion.admonition { 85 | border-left: .2rem solid rgba(255, 150, 235, 0.44); 86 | } 87 | 88 | 89 | .admonition-design-options p.admonition-title { 90 | background-color: rgba(173, 28, 237, 0.44); 91 | } 92 | 93 | div.admonition-design-options.admonition { 94 | border-left: .2rem solid rgba(173, 28, 237, 0.44); 95 | } 96 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | API Reference 3 | =============== 4 | 5 | .. currentmodule:: perfmetrics 6 | 7 | 8 | Decorators 9 | ========== 10 | 11 | .. decorator:: metric 12 | 13 | Notifies Statsd every time the function is called. 14 | 15 | Sends both call counts and timing information. The name of the metric 16 | sent to Statsd is ``.``. 17 | 18 | .. decorator:: metricmethod 19 | 20 | Like ``@metric``, but the name of the Statsd metric is 21 | ``..``. 22 | 23 | .. autoclass:: Metric 24 | .. autoclass:: MetricMod 25 | 26 | 27 | Functions 28 | ========= 29 | 30 | .. autofunction:: statsd_client 31 | .. autofunction:: set_statsd_client 32 | .. autofunction:: statsd_client_from_uri 33 | 34 | 35 | StatsdClient Methods 36 | ==================== 37 | 38 | Python code can send custom metrics by first getting the current 39 | `IStatsdClient` using the `statsd_client()` function. Note that 40 | `statsd_client()` returns None if no client has been configured. 41 | 42 | .. autointerface:: perfmetrics.interfaces.IStatsdClient 43 | 44 | There are three implementations of this interface: 45 | 46 | .. autoclass:: perfmetrics.statsd.StatsdClient 47 | .. autoclass:: perfmetrics.statsd.StatsdClientMod 48 | .. autoclass:: perfmetrics.statsd.NullStatsdClient 49 | 50 | 51 | Pyramid Integration 52 | =================== 53 | 54 | .. autofunction:: includeme 55 | .. autofunction:: tween 56 | 57 | WSGI Integration 58 | ================ 59 | 60 | .. autofunction:: make_statsd_app 61 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGES.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # RelStorage documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jun 22 06:34:58 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # 27 | # needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | 33 | import os 34 | import sys 35 | import pkg_resources 36 | os.environ['PURE_PYTHON'] = '1' 37 | 38 | sys.path.append(os.path.abspath('../src')) 39 | rqmt = pkg_resources.require('perfmetrics')[0] 40 | 41 | 42 | 43 | extensions = [ 44 | 'sphinx.ext.autodoc', 45 | 'sphinx.ext.autosummary', 46 | 'sphinx.ext.doctest', 47 | 'sphinx.ext.intersphinx', 48 | 'sphinx.ext.todo', 49 | 'sphinx.ext.extlinks', 50 | 'sphinx.ext.viewcode', 51 | 'repoze.sphinx.autointerface', 52 | ] 53 | 54 | # Add any paths that contain templates here, relative to this directory. 55 | templates_path = ['_templates'] 56 | 57 | # The suffix(es) of source filenames. 58 | # You can specify multiple suffix as a list of string: 59 | # 60 | # source_suffix = ['.rst', '.md'] 61 | source_suffix = '.rst' 62 | 63 | # The encoding of source files. 64 | # 65 | # source_encoding = 'utf-8-sig' 66 | 67 | # The master toctree document. 68 | master_doc = 'index' 69 | 70 | # General information about the project. 71 | project = u'perfmetrics' 72 | copyright = u'2012-2019, perfmetrics contributors' 73 | author = u'perfmetrics contributors' 74 | 75 | # The version info for the project you're documenting, acts as replacement for 76 | # |version| and |release|, also used in various other places throughout the 77 | # built documents. 78 | # 79 | # The short X.Y version. 80 | version = '%s.%s' % tuple(map(int, rqmt.version.split('.')[:2])) 81 | # The full version, including alpha/beta/rc tags. 82 | release = rqmt.version 83 | 84 | # The language for content autogenerated by Sphinx. Refer to documentation 85 | # for a list of supported languages. 86 | # 87 | # This is also used if you do content translation via gettext catalogs. 88 | # Usually you set "language" from the command line for these cases. 89 | language = 'en' 90 | 91 | # There are two options for replacing |today|: either, you set today to some 92 | # non-false value, then it is used: 93 | # 94 | # today = '' 95 | # 96 | # Else, today_fmt is used as the format for a strftime call. 97 | # 98 | # today_fmt = '%B %d, %Y' 99 | 100 | # List of patterns, relative to source directory, that match files and 101 | # directories to ignore when looking for source files. 102 | # This patterns also effect to html_static_path and html_extra_path 103 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 104 | 105 | # The reST default role (used for this markup: `text`) to use for all 106 | # documents. 107 | # 108 | default_role = 'obj' 109 | 110 | # If true, '()' will be appended to :func: etc. cross-reference text. 111 | # 112 | add_function_parentheses = True 113 | 114 | # If true, the current module name will be prepended to all description 115 | # unit titles (such as .. function::). 116 | # 117 | add_module_names = True 118 | 119 | # If true, sectionauthor and moduleauthor directives will be shown in the 120 | # output. They are ignored by default. 121 | # 122 | # show_authors = False 123 | 124 | # The name of the Pygments (syntax highlighting) style to use. 125 | pygments_style = 'perldoc' 126 | 127 | # A list of ignored prefixes for module index sorting. 128 | modindex_common_prefix = ['perfmetrics.'] 129 | 130 | # If true, keep warnings as "system message" paragraphs in the built documents. 131 | # keep_warnings = False 132 | 133 | # If true, `todo` and `todoList` produce output, else they produce nothing. 134 | todo_include_todos = True 135 | 136 | 137 | # -- Options for HTML output ---------------------------------------------- 138 | 139 | # The theme to use for HTML and HTML Help pages. See the documentation for 140 | # a list of builtin themes. 141 | # 142 | 143 | html_theme = 'furo' 144 | 145 | html_css_files = [ 146 | 'custom.css', 147 | ] 148 | 149 | html_theme_options = { 150 | 'light_css_variables': { 151 | 'font-stack': '"SF Pro",-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', 152 | 'font-stack--monospace': '"JetBrainsMono", "JetBrains Mono", "JetBrains Mono Regular", "JetBrainsMono-Regular", ui-monospace, profont, monospace', 153 | }, 154 | } 155 | # Theme options are theme-specific and customize the look and feel of a theme 156 | # further. For a list of options available for each theme, see the 157 | # documentation. 158 | # 159 | # html_theme_options = {} 160 | 161 | # Add any paths that contain custom themes here, relative to this directory. 162 | # html_theme_path = [] 163 | 164 | # The name for this set of Sphinx documents. 165 | # " v documentation" by default. 166 | # 167 | # html_title = u'RelStorage v1.7' 168 | 169 | # A shorter title for the navigation bar. Default is the same as html_title. 170 | # 171 | # html_short_title = None 172 | 173 | # The name of an image file (relative to this directory) to place at the top 174 | # of the sidebar. 175 | # 176 | # html_logo = None 177 | 178 | # The name of an image file (relative to this directory) to use as a favicon of 179 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 180 | # pixels large. 181 | # 182 | # html_favicon = None 183 | 184 | # Add any paths that contain custom static files (such as style sheets) here, 185 | # relative to this directory. They are copied after the builtin static files, 186 | # so a file named "default.css" will overwrite the builtin "default.css". 187 | html_static_path = ['_static'] 188 | 189 | # Add any extra paths that contain custom files (such as robots.txt or 190 | # .htaccess) here, relative to this directory. These files are copied 191 | # directly to the root of the documentation. 192 | # 193 | # html_extra_path = [] 194 | 195 | # If not None, a 'Last updated on:' timestamp is inserted at every page 196 | # bottom, using the given strftime format. 197 | # The empty string is equivalent to '%b %d, %Y'. 198 | # 199 | # html_last_updated_fmt = None 200 | 201 | # If true, SmartyPants will be used to convert quotes and dashes to 202 | # typographically correct entities. 203 | # 204 | # html_use_smartypants = True 205 | 206 | # Custom sidebar templates, maps document names to template names. 207 | # 208 | # html_sidebars = {} 209 | 210 | # Additional templates that should be rendered to pages, maps page names to 211 | # template names. 212 | # 213 | # html_additional_pages = {} 214 | 215 | # If false, no module index is generated. 216 | # 217 | # html_domain_indices = True 218 | 219 | # If false, no index is generated. 220 | # 221 | # html_use_index = True 222 | 223 | # If true, the index is split into individual pages for each letter. 224 | # 225 | # html_split_index = False 226 | 227 | # If true, links to the reST sources are added to the pages. 228 | # 229 | # html_show_sourcelink = True 230 | 231 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 232 | # 233 | # html_show_sphinx = True 234 | 235 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 236 | # 237 | # html_show_copyright = True 238 | 239 | # If true, an OpenSearch description file will be output, and all pages will 240 | # contain a tag referring to it. The value of this option must be the 241 | # base URL from which the finished HTML is served. 242 | # 243 | # html_use_opensearch = '' 244 | 245 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 246 | # html_file_suffix = None 247 | 248 | # Language to be used for generating the HTML full-text search index. 249 | # Sphinx supports the following languages: 250 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 251 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' 252 | # 253 | # html_search_language = 'en' 254 | 255 | # A dictionary with options for the search language support, empty by default. 256 | # 'ja' uses this config value. 257 | # 'zh' user can custom change `jieba` dictionary path. 258 | # 259 | # html_search_options = {'type': 'default'} 260 | 261 | # The name of a javascript file (relative to the configuration directory) that 262 | # implements a search results scorer. If empty, the default will be used. 263 | # 264 | # html_search_scorer = 'scorer.js' 265 | 266 | # Output file base name for HTML help builder. 267 | htmlhelp_basename = 'perfmetricsdoc' 268 | 269 | # -- Options for LaTeX output --------------------------------------------- 270 | 271 | latex_elements = { 272 | # The paper size ('letterpaper' or 'a4paper'). 273 | # 274 | # 'papersize': 'letterpaper', 275 | 276 | # The font size ('10pt', '11pt' or '12pt'). 277 | # 278 | # 'pointsize': '10pt', 279 | 280 | # Additional stuff for the LaTeX preamble. 281 | # 282 | # 'preamble': '', 283 | 284 | # Latex figure (float) alignment 285 | # 286 | # 'figure_align': 'htbp', 287 | } 288 | 289 | # Grouping the document tree into LaTeX files. List of tuples 290 | # (source start file, target name, title, 291 | # author, documentclass [howto, manual, or own class]). 292 | latex_documents = [ 293 | (master_doc, 'perfmetrics.tex', u'perfmetrics Documentation', 294 | u'perfmetrics contributors', 'manual'), 295 | ] 296 | 297 | # The name of an image file (relative to this directory) to place at the top of 298 | # the title page. 299 | # 300 | # latex_logo = None 301 | 302 | # For "manual" documents, if this is true, then toplevel headings are parts, 303 | # not chapters. 304 | # 305 | # latex_use_parts = False 306 | 307 | # If true, show page references after internal links. 308 | # 309 | # latex_show_pagerefs = False 310 | 311 | # If true, show URL addresses after external links. 312 | # 313 | # latex_show_urls = False 314 | 315 | # Documents to append as an appendix to all manuals. 316 | # 317 | # latex_appendices = [] 318 | 319 | # If false, no module index is generated. 320 | # 321 | # latex_domain_indices = True 322 | 323 | 324 | # -- Options for manual page output --------------------------------------- 325 | 326 | # One entry per manual page. List of tuples 327 | # (source start file, name, description, authors, manual section). 328 | man_pages = [ 329 | (master_doc, 'perfmetrics', u'perfmetrics Documentation', 330 | [author], 1) 331 | ] 332 | 333 | # If true, show URL addresses after external links. 334 | # 335 | # man_show_urls = False 336 | 337 | 338 | # -- Options for Texinfo output ------------------------------------------- 339 | 340 | # Grouping the document tree into Texinfo files. List of tuples 341 | # (source start file, target name, title, author, 342 | # dir menu entry, description, category) 343 | texinfo_documents = [ 344 | (master_doc, 'perfmetrics', u'perfmetrics Documentation', 345 | author, 'perfmetrics', 'One line description of project.', 346 | 'Miscellaneous'), 347 | ] 348 | 349 | # Documents to append as an appendix to all manuals. 350 | # 351 | # texinfo_appendices = [] 352 | 353 | # If false, no module index is generated. 354 | # 355 | # texinfo_domain_indices = True 356 | 357 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 358 | # 359 | # texinfo_show_urls = 'footnote' 360 | 361 | # If true, do not generate a @detailmenu in the "Top" node's menu. 362 | # 363 | # texinfo_no_detailmenu = False 364 | 365 | 366 | # Example configuration for intersphinx: refer to the Python standard library. 367 | 368 | 369 | intersphinx_mapping = { 370 | 'python': ('http://docs.python.org/', None,), 371 | } 372 | 373 | extlinks = {'issue': ('https://github.com/zodb/perfmetrics/issues/%s', 374 | 'issue #'), 375 | 'pr': ('https://github.com/zodb/perfmetrics/pull/%s', 376 | 'pull request #')} 377 | 378 | 379 | # Sphinx 1.8+ prefers this to `autodoc_default_flags`. It's documented that 380 | # either True or None mean the same thing as just setting the flag, but 381 | # only None works in 1.8 (True works in 2.0) 382 | autodoc_default_options = { 383 | 'members': None, 384 | 'show-inheritance': True, 385 | 'private-members': None, 386 | 'special-members': '__enter__, __exit__', 387 | } 388 | autodoc_member_order = 'groupwise' 389 | autoclass_content = 'both' 390 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | api 9 | testing 10 | changelog 11 | 12 | 13 | ==================== 14 | Indices and tables 15 | ==================== 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | -------------------------------------------------------------------------------- /docs/testing.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Testing 3 | ========= 4 | 5 | ``perfmetrics.testing`` provides a testing client for verifying StatsD 6 | metrics emitted by perfmetrics in the context of an instrumented 7 | application. 8 | 9 | It's easy to create a new client for use in testing: 10 | 11 | .. code-block:: pycon 12 | 13 | >>> from perfmetrics.testing import FakeStatsDClient 14 | >>> test_client = FakeStatsDClient() 15 | 16 | This client exposes the same public interface as 17 | `perfmetrics.statsd.StatsdClient`. For example we can increment 18 | counters, set gauges, etc: 19 | 20 | .. code-block:: pycon 21 | 22 | >>> test_client.incr('request_c') 23 | >>> test_client.gauge('active_sessions', 320) 24 | 25 | Unlike `perfmetrics.statsd.StatsdClient`, `~.FakeStatsDClient` simply 26 | tracks the statsd packets that would be sent. This information is 27 | exposed on our ``test_client`` both as the raw statsd packet, and for 28 | convenience this information is also parsed and exposed as `~.Observation` 29 | objects. For complete details see `~.FakeStatsDClient` and `~.Observation`. 30 | 31 | .. code-block:: pycon 32 | 33 | >>> test_client.packets 34 | ['request_c:1|c', 'active_sessions:320|g'] 35 | >>> test_client.observations 36 | [Observation(name='request_c', value='1', kind='c', sampling_rate=None), Observation(name='active_sessions', value='320', kind='g', sampling_rate=None)] 37 | 38 | For validating metrics we provide a set of `PyHamcrest 39 | `_ matchers for use in test 40 | assertions: 41 | 42 | .. code-block:: pycon 43 | 44 | >>> from hamcrest import assert_that 45 | >>> from hamcrest import contains_exactly as contains 46 | >>> from perfmetrics.testing.matchers import is_observation 47 | >>> from perfmetrics.testing.matchers import is_gauge 48 | 49 | We can use both strings and numbers (or any matcher) for the value: 50 | 51 | >>> assert_that(test_client, 52 | ... contains(is_observation('c', 'request_c', '1'), 53 | ... is_gauge('active_sessions', 320))) 54 | >>> assert_that(test_client, 55 | ... contains(is_observation('c', 'request_c', '1'), 56 | ... is_gauge('active_sessions', '320'))) 57 | >>> from hamcrest import is_ 58 | >>> assert_that(test_client, 59 | ... contains(is_observation('c', 'request_c', '1'), 60 | ... is_gauge('active_sessions', is_('320')))) 61 | 62 | If the matching fails, we get a descriptive error: 63 | 64 | >>> assert_that(test_client, 65 | ... contains(is_gauge('request_c', '1'), 66 | ... is_gauge('active_sessions', '320'))) 67 | Traceback (most recent call last): 68 | ... 69 | AssertionError: 70 | Expected: a sequence containing [(an instance of Observation and (an object with a property 'kind' matching 'g' and an object with a property 'name' matching 'request_c' and an object with a property 'value' matching '1')), (an instance of Observation and (an object with a property 'kind' matching 'g' and an object with a property 'name' matching 'active_sessions' and an object with a property 'value' matching '320'))] 71 | but: item 0: was Observation(name='request_c', value='1', kind='c', sampling_rate=None) 72 | 73 | 74 | Reference 75 | ========= 76 | 77 | ``perfmetrics.testing`` 78 | ----------------------- 79 | .. currentmodule:: perfmetrics.testing 80 | 81 | .. automodule:: perfmetrics.testing.client 82 | :special-members: __len__, __iter___ 83 | .. automodule:: perfmetrics.testing.observation 84 | 85 | ``perfmetrics.testing.matchers`` 86 | -------------------------------- 87 | .. automodule:: perfmetrics.testing.matchers 88 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # Our setup.py might import other things from this directory, meaning 3 | # in needs to be on sys.path. That's not guaranteed in a PEP517 world; 4 | # the __legacy__ build module makes that true. Ultimately we need to do that 5 | # ourself (and/or continue to simplify our build system). 6 | build-backend = "setuptools.build_meta:__legacy__" 7 | requires = [ 8 | "setuptools >= 40.8.0", 9 | "wheel", 10 | 11 | # Python 3.7 requires at least Cython 0.27.3. 12 | # 0.28 is faster, and (important!) lets us specify the target module 13 | # name to be created so that we can have both foo.py and _foo.so 14 | # at the same time. 0.29 fixes some issues with Python 3.7, 15 | # and adds the 3str mode for transition to Python 3. 0.29.14+ is 16 | # required for Python 3.8. 3.0a2 introduced a change that prevented 17 | # us from compiling (https://github.com/gevent/gevent/issues/1599) 18 | # but once that was fixed, 3.0a4 led to all of our leak tests 19 | # failing in Python 2 (https://travis-ci.org/github/gevent/gevent/jobs/683782800); 20 | # This was fixed in 3.0a5 (https://github.com/cython/cython/issues/3578) 21 | # 3.0a6 fixes an issue cythonizing source on 32-bit platforms 22 | "Cython >= 3.0.10; platform_python_implementation == 'CPython'", 23 | ] 24 | -------------------------------------------------------------------------------- /scripts/install.ps1: -------------------------------------------------------------------------------- 1 | # Sample script to install Python and pip under Windows 2 | # Authors: Olivier Grisel, Jonathan Helmus, Kyle Kastner, and Alex Willmer 3 | # License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ 4 | 5 | $MINICONDA_URL = "http://repo.continuum.io/miniconda/" 6 | $BASE_URL = "https://www.python.org/ftp/python/" 7 | $GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" 8 | $GET_PIP_PATH = "C:\get-pip.py" 9 | 10 | $PYTHON_PRERELEASE_REGEX = @" 11 | (?x) 12 | (?\d+) 13 | \. 14 | (?\d+) 15 | \. 16 | (?\d+) 17 | (?[a-z]{1,2}\d+) 18 | "@ 19 | 20 | 21 | function Download ($filename, $url) { 22 | $webclient = New-Object System.Net.WebClient 23 | 24 | $basedir = $pwd.Path + "\" 25 | $filepath = $basedir + $filename 26 | if (Test-Path $filename) { 27 | Write-Host "Reusing" $filepath 28 | return $filepath 29 | } 30 | 31 | # Download and retry up to 3 times in case of network transient errors. 32 | Write-Host "Downloading" $filename "from" $url 33 | $retry_attempts = 2 34 | for ($i = 0; $i -lt $retry_attempts; $i++) { 35 | try { 36 | $webclient.DownloadFile($url, $filepath) 37 | break 38 | } 39 | Catch [Exception]{ 40 | Start-Sleep 1 41 | } 42 | } 43 | if (Test-Path $filepath) { 44 | Write-Host "File saved at" $filepath 45 | } else { 46 | # Retry once to get the error message if any at the last try 47 | $webclient.DownloadFile($url, $filepath) 48 | } 49 | return $filepath 50 | } 51 | 52 | 53 | function ParsePythonVersion ($python_version) { 54 | if ($python_version -match $PYTHON_PRERELEASE_REGEX) { 55 | return ([int]$matches.major, [int]$matches.minor, [int]$matches.micro, 56 | $matches.prerelease) 57 | } 58 | $version_obj = [version]$python_version 59 | return ($version_obj.major, $version_obj.minor, $version_obj.build, "") 60 | } 61 | 62 | 63 | function DownloadPython ($python_version, $platform_suffix) { 64 | $major, $minor, $micro, $prerelease = ParsePythonVersion $python_version 65 | 66 | if (($major -le 2 -and $micro -eq 0) ` 67 | -or ($major -eq 3 -and $minor -le 2 -and $micro -eq 0) ` 68 | ) { 69 | $dir = "$major.$minor" 70 | $python_version = "$major.$minor$prerelease" 71 | } else { 72 | $dir = "$major.$minor.$micro" 73 | } 74 | 75 | if ($prerelease) { 76 | if (($major -le 2) ` 77 | -or ($major -eq 3 -and $minor -eq 1) ` 78 | -or ($major -eq 3 -and $minor -eq 2) ` 79 | -or ($major -eq 3 -and $minor -eq 3) ` 80 | ) { 81 | $dir = "$dir/prev" 82 | } 83 | } 84 | 85 | if (($major -le 2) -or ($major -le 3 -and $minor -le 4)) { 86 | $ext = "msi" 87 | if ($platform_suffix) { 88 | $platform_suffix = ".$platform_suffix" 89 | } 90 | } else { 91 | $ext = "exe" 92 | if ($platform_suffix) { 93 | $platform_suffix = "-$platform_suffix" 94 | } 95 | } 96 | 97 | $filename = "python-$python_version$platform_suffix.$ext" 98 | $url = "$BASE_URL$dir/$filename" 99 | $filepath = Download $filename $url 100 | return $filepath 101 | } 102 | 103 | 104 | function InstallPython ($python_version, $architecture, $python_home) { 105 | Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home 106 | if (Test-Path $python_home) { 107 | Write-Host $python_home "already exists, skipping." 108 | return $false 109 | } 110 | if ($architecture -eq "32") { 111 | $platform_suffix = "" 112 | } else { 113 | $platform_suffix = "amd64" 114 | } 115 | $installer_path = DownloadPython $python_version $platform_suffix 116 | $installer_ext = [System.IO.Path]::GetExtension($installer_path) 117 | Write-Host "Installing $installer_path to $python_home" 118 | $install_log = $python_home + ".log" 119 | if ($installer_ext -eq '.msi') { 120 | InstallPythonMSI $installer_path $python_home $install_log 121 | } else { 122 | InstallPythonEXE $installer_path $python_home $install_log 123 | } 124 | if (Test-Path $python_home) { 125 | Write-Host "Python $python_version ($architecture) installation complete" 126 | } else { 127 | Write-Host "Failed to install Python in $python_home" 128 | Get-Content -Path $install_log 129 | Exit 1 130 | } 131 | } 132 | 133 | 134 | function InstallPythonEXE ($exepath, $python_home, $install_log) { 135 | $install_args = "/quiet InstallAllUsers=1 TargetDir=$python_home" 136 | RunCommand $exepath $install_args 137 | } 138 | 139 | 140 | function InstallPythonMSI ($msipath, $python_home, $install_log) { 141 | $install_args = "/qn /log $install_log /i $msipath TARGETDIR=$python_home" 142 | $uninstall_args = "/qn /x $msipath" 143 | RunCommand "msiexec.exe" $install_args 144 | if (-not(Test-Path $python_home)) { 145 | Write-Host "Python seems to be installed else-where, reinstalling." 146 | RunCommand "msiexec.exe" $uninstall_args 147 | RunCommand "msiexec.exe" $install_args 148 | } 149 | } 150 | 151 | function RunCommand ($command, $command_args) { 152 | Write-Host $command $command_args 153 | Start-Process -FilePath $command -ArgumentList $command_args -Wait -Passthru 154 | } 155 | 156 | 157 | function InstallPip ($python_home) { 158 | $pip_path = $python_home + "\Scripts\pip.exe" 159 | $python_path = $python_home + "\python.exe" 160 | if (-not(Test-Path $pip_path)) { 161 | Write-Host "Installing pip..." 162 | $webclient = New-Object System.Net.WebClient 163 | $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) 164 | Write-Host "Executing:" $python_path $GET_PIP_PATH 165 | & $python_path $GET_PIP_PATH 166 | } else { 167 | Write-Host "pip already installed." 168 | } 169 | } 170 | 171 | 172 | function DownloadMiniconda ($python_version, $platform_suffix) { 173 | if ($python_version -eq "3.4") { 174 | $filename = "Miniconda3-3.5.5-Windows-" + $platform_suffix + ".exe" 175 | } else { 176 | $filename = "Miniconda-3.5.5-Windows-" + $platform_suffix + ".exe" 177 | } 178 | $url = $MINICONDA_URL + $filename 179 | $filepath = Download $filename $url 180 | return $filepath 181 | } 182 | 183 | 184 | function InstallMiniconda ($python_version, $architecture, $python_home) { 185 | Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home 186 | if (Test-Path $python_home) { 187 | Write-Host $python_home "already exists, skipping." 188 | return $false 189 | } 190 | if ($architecture -eq "32") { 191 | $platform_suffix = "x86" 192 | } else { 193 | $platform_suffix = "x86_64" 194 | } 195 | $filepath = DownloadMiniconda $python_version $platform_suffix 196 | Write-Host "Installing" $filepath "to" $python_home 197 | $install_log = $python_home + ".log" 198 | $args = "/S /D=$python_home" 199 | Write-Host $filepath $args 200 | Start-Process -FilePath $filepath -ArgumentList $args -Wait -Passthru 201 | if (Test-Path $python_home) { 202 | Write-Host "Python $python_version ($architecture) installation complete" 203 | } else { 204 | Write-Host "Failed to install Python in $python_home" 205 | Get-Content -Path $install_log 206 | Exit 1 207 | } 208 | } 209 | 210 | 211 | function InstallMinicondaPip ($python_home) { 212 | $pip_path = $python_home + "\Scripts\pip.exe" 213 | $conda_path = $python_home + "\Scripts\conda.exe" 214 | if (-not(Test-Path $pip_path)) { 215 | Write-Host "Installing pip..." 216 | $args = "install --yes pip" 217 | Write-Host $conda_path $args 218 | Start-Process -FilePath "$conda_path" -ArgumentList $args -Wait -Passthru 219 | } else { 220 | Write-Host "pip already installed." 221 | } 222 | } 223 | 224 | function main () { 225 | InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON 226 | InstallPip $env:PYTHON 227 | } 228 | 229 | main 230 | -------------------------------------------------------------------------------- /scripts/releases/geventrel.sh: -------------------------------------------------------------------------------- 1 | #!/opt/local/bin/bash 2 | # 3 | # Quick hack script to build a single gevent release in a virtual env. Takes one 4 | # argument, the path to python to use. 5 | # Has hardcoded paths, probably only works on my (JAM) machine. 6 | 7 | set -e 8 | export WORKON_HOME=$HOME/Projects/VirtualEnvs 9 | export VIRTUALENVWRAPPER_LOG_DIR=~/.virtualenvs 10 | source `which virtualenvwrapper.sh` 11 | 12 | # Make sure there are no -march flags set 13 | # https://github.com/gevent/gevent/issues/791 14 | unset CFLAGS 15 | unset CXXFLAGS 16 | unset CPPFLAGS 17 | # But we do need these, otherwise we get universal2 wheels that are not actually universal 2. 18 | export _PYTHON_HOST_PLATFORM="macosx-11.0-arm64" 19 | export ARCHFLAGS="-arch arm64" 20 | 21 | # If we're building on 10.12, we have to exclude clock_gettime 22 | # because it's not available on earlier releases and leads to 23 | # segfaults because the symbol clock_gettime is NULL. 24 | # See https://github.com/gevent/gevent/issues/916 25 | export CPPFLAGS="-D_DARWIN_FEATURE_CLOCK_GETTIME=0" 26 | 27 | BASE=`pwd`/../../ 28 | BASE=`greadlink -f $BASE` 29 | 30 | 31 | cd /tmp/gevent 32 | virtualenv -p $1 `basename $1` 33 | cd `basename $1` 34 | echo "Made tmpenv" 35 | echo `pwd` 36 | source bin/activate 37 | echo cloning $BASE 38 | git clone $BASE gevent 39 | cd ./gevent 40 | pip install -U pip setuptools wheel 41 | pip wheel . -w dist 42 | cp dist/perf*whl /tmp/gevent/ 43 | -------------------------------------------------------------------------------- /scripts/releases/geventreleases.sh: -------------------------------------------------------------------------------- 1 | #!/opt/local/bin/bash 2 | 3 | # Quick hack script to create many gevent releases. 4 | # Contains hardcoded paths. Probably only works on my (JAM) machine 5 | # (OS X 10.11) 6 | 7 | mkdir /tmp/gevent/ 8 | 9 | 10 | ./geventrel.sh /usr/local/bin/python3.8 11 | ./geventrel.sh /usr/local/bin/python3.9 12 | ./geventrel.sh /usr/local/bin/python3.10 13 | ./geventrel.sh /usr/local/bin/python3.11 14 | ./geventrel.sh /usr/local/bin/python3.12 15 | 16 | 17 | # PyPy doesn't build binaries, resulting in a none-any wheel. 18 | # I don't think we want to publish that. If we have no wheel for a platform, 19 | # we want them to get the .tar.gz sdist so they can build the C accelerator. 20 | #./geventrel.sh `which pypy3` 21 | 22 | wait 23 | -------------------------------------------------------------------------------- /scripts/releases/make-manylinux: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Initially based on a snippet from the greenlet project. 3 | # This needs to be run from the root of the project. 4 | # To update: docker pull quay.io/pypa/manylinux2010_x86_64 5 | set -e 6 | export PYTHONUNBUFFERED=1 7 | export PYTHONDONTWRITEBYTECODE=1 8 | 9 | # Don't get warnings about Python 2 support being deprecated. We 10 | # know. The env var works for pip 20. 11 | export PIP_NO_PYTHON_VERSION_WARNING=1 12 | export PIP_NO_WARN_SCRIPT_LOCATION=1 13 | 14 | # Build configuration. 15 | export CFLAGS="-pipe -O3 -DNDEBUG" 16 | export CXXFLAGS="-pipe -O3 -DNDEBUG" 17 | 18 | if [ -d /io -a -d /opt/python ]; then 19 | # Running inside docker 20 | # 2024-06-11: git started complaining "fatal: detected dubious ownership in repository at '/io/.git'" 21 | git config --global --add safe.directory /io/.git 22 | cd /io 23 | rm -rf wheelhouse 24 | mkdir wheelhouse 25 | # non-gil won't build, seems like a cython issue. 26 | rm -f /opt/python/cp313-cp313t 27 | for variant in `ls -d /opt/python/cp{38,39,310,311,312,313}*`; do 28 | echo "Building $variant" 29 | mkdir /tmp/build 30 | cd /tmp/build 31 | git clone /io io 32 | cd io 33 | $variant/bin/pip install -U pip 34 | $variant/bin/pip install -U 'cython>=3.0.10' setuptools build wheel 35 | PATH=$variant/bin:$PATH $variant/bin/python -m build --wheel 36 | auditwheel show dist/perfmetrics*.whl 37 | auditwheel repair dist/perfmetrics*.whl 38 | cp wheelhouse/perfmetrics*.whl /io/wheelhouse 39 | cd /io 40 | rm -rf /tmp/build 41 | done 42 | rm -rf dist build *.egg-info 43 | exit 0 44 | fi 45 | 46 | docker run --rm -e GITHUB_ACTIONS -e DOCKER_IMAGE -v "$(pwd):/io" ${DOCKER_IMAGE:-quay.io/pypa/manylinux2010_x86_64} /io/scripts/releases/$(basename $0) 47 | ls -l wheelhouse 48 | -------------------------------------------------------------------------------- /scripts/run_with_env.cmd: -------------------------------------------------------------------------------- 1 | :: To build extensions for 64 bit Python 3, we need to configure environment 2 | :: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: 3 | :: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) 4 | :: 5 | :: To build extensions for 64 bit Python 2, we need to configure environment 6 | :: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: 7 | :: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) 8 | :: 9 | :: 32 bit builds, and 64-bit builds for 3.5 and beyond, do not require specific 10 | :: environment configurations. 11 | :: 12 | :: Note: this script needs to be run with the /E:ON and /V:ON flags for the 13 | :: cmd interpreter, at least for (SDK v7.0) 14 | :: 15 | :: More details at: 16 | :: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows 17 | :: http://stackoverflow.com/a/13751649/163740 18 | :: 19 | :: Author: Olivier Grisel 20 | :: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ 21 | :: 22 | :: Notes about batch files for Python people: 23 | :: 24 | :: Quotes in values are literally part of the values: 25 | :: SET FOO="bar" 26 | :: FOO is now five characters long: " b a r " 27 | :: If you don't want quotes, don't include them on the right-hand side. 28 | :: 29 | :: The CALL lines at the end of this file look redundant, but if you move them 30 | :: outside of the IF clauses, they do not run properly in the SET_SDK_64==Y 31 | :: case, I don't know why. 32 | @ECHO OFF 33 | 34 | SET COMMAND_TO_RUN=%* 35 | SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows 36 | SET WIN_WDK=c:\Program Files (x86)\Windows Kits\10\Include\wdf 37 | 38 | :: Extract the major and minor versions, and allow for the minor version to be 39 | :: more than 9. This requires the version number to have two dots in it. 40 | SET MAJOR_PYTHON_VERSION=%PYTHON_VERSION:~0,1% 41 | IF "%PYTHON_VERSION:~3,1%" == "." ( 42 | SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,1% 43 | ) ELSE ( 44 | SET MINOR_PYTHON_VERSION=%PYTHON_VERSION:~2,2% 45 | ) 46 | 47 | :: Based on the Python version, determine what SDK version to use, and whether 48 | :: to set the SDK for 64-bit. 49 | IF %MAJOR_PYTHON_VERSION% == 2 ( 50 | SET WINDOWS_SDK_VERSION="v7.0" 51 | SET SET_SDK_64=Y 52 | ) ELSE ( 53 | IF %MAJOR_PYTHON_VERSION% == 3 ( 54 | SET WINDOWS_SDK_VERSION="v7.1" 55 | IF %MINOR_PYTHON_VERSION% LEQ 4 ( 56 | SET SET_SDK_64=Y 57 | ) ELSE ( 58 | SET SET_SDK_64=N 59 | IF EXIST "%WIN_WDK%" ( 60 | :: See: https://connect.microsoft.com/VisualStudio/feedback/details/1610302/ 61 | REN "%WIN_WDK%" 0wdf 62 | ) 63 | ) 64 | ) ELSE ( 65 | ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" 66 | EXIT 1 67 | ) 68 | ) 69 | 70 | IF %PYTHON_ARCH% == 64 ( 71 | IF %SET_SDK_64% == Y ( 72 | ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture 73 | SET DISTUTILS_USE_SDK=1 74 | SET MSSdk=1 75 | "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% 76 | "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release 77 | ECHO Executing: %COMMAND_TO_RUN% 78 | call %COMMAND_TO_RUN% || EXIT 1 79 | ) ELSE ( 80 | ECHO Using default MSVC build environment for 64 bit architecture 81 | ECHO Executing: %COMMAND_TO_RUN% 82 | call %COMMAND_TO_RUN% || EXIT 1 83 | ) 84 | ) ELSE ( 85 | ECHO Using default MSVC build environment for 32 bit architecture 86 | ECHO Executing: %COMMAND_TO_RUN% 87 | call %COMMAND_TO_RUN% || EXIT 1 88 | ) 89 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 0 3 | 4 | [check-manifest] 5 | ignore = 6 | *.c 7 | 8 | [zest.releaser] 9 | create-wheel = no 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import setup 5 | from setuptools import find_packages 6 | from setuptools import Extension 7 | 8 | PYPY = hasattr(sys, 'pypy_version_info') 9 | PY312 = sys.version_info[:2] == (3, 12) 10 | 11 | def read(fname, here=os.path.dirname(__file__)): 12 | with open(os.path.join(here, fname), encoding='utf-8') as f: 13 | return f.read() 14 | 15 | README = read('README.rst') 16 | CHANGES = read('CHANGES.rst') 17 | 18 | tests_require = [ 19 | 'zope.testrunner', 20 | # nti.testing > ZODB > persistent -> cffi 21 | # CffI won't build on 3.13 yet; persistent is having trouble on PyPy 22 | 'nti.testing; python_version != "3.13" and platform_python_implementation != "PyPy"', 23 | 24 | # transitive dep of nti.testing, which we don't always have, but need 25 | # for our emulation 26 | 'zope.schema', 27 | 'pyhamcrest >= 1.10', 28 | 'pyperf', 29 | ] 30 | 31 | ### 32 | # Cython 33 | ### 34 | 35 | # Based on code from 36 | # http://cython.readthedocs.io/en/latest/src/reference/compilation.html#distributing-cython-modules 37 | def _dummy_cythonize(extensions, **_kwargs): 38 | for extension in extensions: 39 | sources = [] 40 | for sfile in extension.sources: 41 | path, ext = os.path.splitext(sfile) 42 | if ext in ('.pyx', '.py'): 43 | ext = '.c' 44 | sfile = path + ext 45 | sources.append(sfile) 46 | extension.sources[:] = sources 47 | return extensions 48 | 49 | try: 50 | from Cython.Build import cythonize 51 | except ImportError: 52 | # The .c files had better already exist, as they should in 53 | # an sdist. 54 | cythonize = _dummy_cythonize 55 | 56 | ext_modules = [] 57 | 58 | # Modules we want to compile with Cython. These *should* have a parallel 59 | # .pxd file (with a leading _) defining cython attributes. 60 | # They should also have a cython comment at the top giving options, 61 | # and mention that they are compiled with cython on CPython. 62 | # The bottom of the file must call import_c_accel. 63 | # We use the support from Cython 28 to be able to parallel compile 64 | # and cythonize modules to a different name with a leading _. 65 | # This list is derived from the profile of bm_simple_iface 66 | # https://github.com/NextThought/nti.externalization/commit/0bc4733aa8158acd0d23c14de2f9347fb698c040 67 | if not PYPY: 68 | def _source(m, ext): 69 | # Yes, always /. 70 | m = m.replace('.', '/') 71 | return 'src/perfmetrics/' + m + '.' + ext 72 | def _py_source(m): 73 | return _source(m, 'py') 74 | def _pxd(m): 75 | return _source(m, 'pxd') 76 | def _c(m): 77 | return _source(m, 'c') 78 | # Each module should list the python name of the 79 | # modules it cimports from as deps. We'll generate the rest. 80 | # (Not that this actually appears to do anything right now.) 81 | 82 | for mod_name, deps in ( 83 | ('metric', ()), 84 | ): 85 | deps = ([_py_source(mod) for mod in deps] 86 | + [_pxd(mod) for mod in deps] 87 | + [_c(mod) for mod in deps]) 88 | 89 | source = _py_source(mod_name) 90 | # 'foo.bar' -> 'foo._bar' 91 | mod_name_parts = mod_name.rsplit('.', 1) 92 | mod_name_parts[-1] = '_' + mod_name_parts[-1] 93 | mod_name = '.'.join(mod_name_parts) 94 | 95 | 96 | ext_modules.append( 97 | Extension( 98 | 'perfmetrics.' + mod_name, 99 | sources=[source], 100 | depends=deps, 101 | define_macros=[('CYTHON_TRACE', '0')], 102 | )) 103 | 104 | try: 105 | ext_modules = cythonize( 106 | ext_modules, 107 | annotate=True, 108 | compiler_directives={ 109 | #'linetrace': True, 110 | 'infer_types': True, 111 | 'language_level': '3str', 112 | 'always_allow_keywords': False, 113 | 'nonecheck': False, 114 | }, 115 | ) 116 | except ValueError: 117 | # 'invalid literal for int() with base 10: '3str' 118 | # This is seen when an older version of Cython is installed. 119 | # It's a bit of a chicken-and-egg, though, because installing 120 | # from dev-requirements first scans this egg for its requirements 121 | # before doing any updates. 122 | import traceback 123 | traceback.print_exc() 124 | ext_modules = _dummy_cythonize(ext_modules) 125 | 126 | setup( 127 | name='perfmetrics', 128 | version='4.1.1.dev0', 129 | author='Shane Hathaway', 130 | author_email='shane@hathawaymix.org', 131 | maintainer='Jason Madden', 132 | maintainer_email='jason@nextthought.com', 133 | description='Send performance metrics about Python code to Statsd', 134 | keywords="statsd metrics performance monitoring", 135 | long_description=README + '\n\n' + CHANGES, 136 | python_requires=">=3.7", 137 | # Get strings from https://pypi.org/pypi?%3Aaction=list_classifiers 138 | classifiers=[ 139 | "Development Status :: 5 - Production/Stable", 140 | "Intended Audience :: Developers", 141 | "Programming Language :: Python :: 3", 142 | "Programming Language :: Python :: 3 :: Only", 143 | "Programming Language :: Python :: 3.8", 144 | "Programming Language :: Python :: 3.9", 145 | "Programming Language :: Python :: 3.10", 146 | "Programming Language :: Python :: 3.11", 147 | "Programming Language :: Python :: 3.12", 148 | "Programming Language :: Python :: 3.13", 149 | "Programming Language :: Python :: Implementation :: CPython", 150 | "Programming Language :: Python :: Implementation :: PyPy", 151 | "License :: Repoze Public License", 152 | "Topic :: System :: Monitoring", 153 | ], 154 | url="https://github.com/zodb/perfmetrics", 155 | project_urls={ 156 | 'Bug Tracker': 'https://github.com/zodb/perfmetrics/issues', 157 | 'Source Code': 'https://github.com/zodb/perfmetrics/', 158 | 'Documentation': 'https://perfmetrics.readthedocs.io', 159 | }, 160 | license='BSD-derived (http://www.repoze.org/LICENSE.txt)', 161 | packages=find_packages('src'), 162 | package_dir={'': 'src'}, 163 | ext_modules=ext_modules, 164 | include_package_data=True, 165 | zip_safe=True, 166 | tests_require=tests_require, 167 | install_requires=[ 168 | 'setuptools', 169 | ], 170 | extras_require={ 171 | 'test': tests_require, 172 | 'docs': [ 173 | 'Sphinx >= 2.1.2', 174 | 'repoze.sphinx.autointerface', 175 | 'pyhamcrest', 176 | 'furo', 177 | ], 178 | }, 179 | entry_points="""\ 180 | [paste.filter_app_factory] 181 | statsd = perfmetrics:make_statsd_app 182 | """, 183 | ) 184 | -------------------------------------------------------------------------------- /src/perfmetrics/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | perfmetrics is a library for sending software performance metrics 4 | to statsd. 5 | """ 6 | from __future__ import absolute_import 7 | from __future__ import division 8 | from __future__ import print_function 9 | 10 | import os 11 | 12 | 13 | from .clientstack import statsd_client 14 | from .clientstack import set_statsd_client 15 | from .clientstack import client_stack as statsd_client_stack 16 | 17 | 18 | from .statsd import statsd_client_from_uri 19 | from .statsd import StatsdClient 20 | 21 | from .metric import Metric 22 | from .metric import MetricMod 23 | 24 | from .pyramid import includeme 25 | from .pyramid import tween 26 | from .wsgi import make_statsd_app 27 | 28 | __all__ = [ 29 | 'metric', 30 | 'metricmethod', 31 | 'Metric', 32 | 'MetricMod', 33 | 'StatsdClient', 34 | 'set_statsd_client', 35 | 'statsd_client', 36 | 'statsd_client_from_uri', 37 | 'statsd_client_stack', 38 | # Pyramid 39 | 'includeme', 40 | 'tween', 41 | # WSGI 42 | 'make_statsd_app', 43 | ] 44 | 45 | class _DisabledMetricDecorator(Metric): 46 | 47 | def __call__(self, f): 48 | return f 49 | 50 | class _DisabledMetricModDecorator(MetricMod): 51 | 52 | def __call__(self, f): 53 | return f 54 | 55 | if os.environ.get('PERFMETRICS_DISABLE_DECORATOR'): 56 | Metric = _DisabledMetricDecorator 57 | MetricMod = _DisabledMetricModDecorator 58 | 59 | #: @metric: 60 | metric = Metric() 61 | 62 | #: @metricmethod: 63 | metricmethod = Metric(method=True) 64 | 65 | 66 | _uri = os.environ.get('STATSD_URI') 67 | if _uri: 68 | set_statsd_client(_uri) # pragma: no cover 69 | -------------------------------------------------------------------------------- /src/perfmetrics/_metric.pxd: -------------------------------------------------------------------------------- 1 | # definitions for metric.py 2 | 3 | import cython 4 | 5 | cdef time 6 | cdef MethodType 7 | cdef WeakKeyDictionary 8 | cdef functools 9 | cdef stdrandom 10 | 11 | cdef statsd_client 12 | cdef statsd_client_stack 13 | cdef StatsdClientMod 14 | cdef null_client 15 | 16 | cdef class _MethodLikeMixin(object): 17 | pass 18 | 19 | cdef class _AbstractMetricImpl(_MethodLikeMixin): 20 | cdef public bint metric_timing 21 | cdef public bint metric_count 22 | cdef public double metric_rate 23 | cdef f 24 | cdef str timing_format 25 | cdef public __wrapped__ 26 | cdef dict __dict__ 27 | 28 | cdef str _compute_stat(self, tuple args) 29 | 30 | 31 | cdef class _GivenStatMetricImpl(_AbstractMetricImpl): 32 | cdef readonly str stat_name 33 | 34 | cdef class _MethodMetricImpl(_AbstractMetricImpl): 35 | 36 | cdef klass_dict 37 | 38 | 39 | cdef class Metric(object): 40 | cdef public double rate 41 | cdef double start 42 | cdef public bint method 43 | cdef public bint count 44 | cdef public bint timing 45 | cdef public str stat 46 | cdef public str timing_format 47 | cdef random 48 | cdef dict __dict__ 49 | -------------------------------------------------------------------------------- /src/perfmetrics/_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Helper functions. 4 | 5 | """ 6 | from __future__ import absolute_import 7 | from __future__ import division 8 | from __future__ import print_function 9 | 10 | import os 11 | import sys 12 | 13 | PY3 = sys.version_info[0] >= 3 14 | PYPY = hasattr(sys, 'pypy_version_info') 15 | WIN = sys.platform.startswith("win") 16 | LINUX = sys.platform.startswith('linux') 17 | OSX = sys.platform == 'darwin' 18 | 19 | 20 | PURE_PYTHON = PYPY or os.getenv('PURE_PYTHON') or os.getenv("PERFMETRICS_PURE_PYTHON") 21 | 22 | def import_c_accel(globs, cname): 23 | """ 24 | Import the C-accelerator for the __name__ 25 | and copy its globals. 26 | """ 27 | 28 | name = globs.get('__name__') 29 | 30 | if not name or name == cname: 31 | # Do nothing if we're being exec'd as a file (no name) 32 | # or we're running from the C extension 33 | return # pragma: no cover 34 | 35 | 36 | if not PURE_PYTHON: # pragma: no cover 37 | import importlib 38 | import warnings 39 | with warnings.catch_warnings(): 40 | # Python 3.7 likes to produce 41 | # "ImportWarning: can't resolve 42 | # package from __spec__ or __package__, falling back on 43 | # __name__ and __path__" 44 | # when we load cython compiled files. This is probably a bug in 45 | # Cython, but it doesn't seem to have any consequences, it's 46 | # just annoying to see and can mess up our unittests. 47 | warnings.simplefilter('ignore', ImportWarning) 48 | mod = importlib.import_module(cname) 49 | 50 | # By adopting the entire __dict__, we get a more accurate 51 | # __file__ and module repr, plus we don't leak any imported 52 | # things we no longer need. 53 | globs.clear() 54 | globs.update(mod.__dict__) 55 | 56 | if 'import_c_accel' in globs: 57 | del globs['import_c_accel'] 58 | -------------------------------------------------------------------------------- /src/perfmetrics/clientstack.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import division 4 | from __future__ import print_function 5 | 6 | import threading 7 | 8 | from .statsd import statsd_client_from_uri 9 | 10 | string_types = (str,) 11 | if str is bytes: # pragma: no cover 12 | string_types += (unicode,) # pylint:disable=undefined-variable 13 | 14 | class ClientStack(threading.local): 15 | """ 16 | Thread local stack of StatsdClients. 17 | 18 | Applications and tests can either set the global statsd client using 19 | perfmetrics.set_statsd_client() or set a statsd client for each thread 20 | using statsd_client_stack.push()/.pop()/.clear(). 21 | 22 | This is like pyramid.threadlocal but it handles the default differently. 23 | """ 24 | 25 | default = None 26 | 27 | def __init__(self): 28 | threading.local.__init__(self) # pylint:disable=non-parent-init-called 29 | self.stack = [] 30 | 31 | def get(self): 32 | """ 33 | Return the current StatsdClient for the thread. 34 | 35 | Returns the thread-local client if there is one, or the global 36 | client if there is one, or None. 37 | """ 38 | stack = self.stack 39 | return stack[-1] if stack else self.default 40 | 41 | def push(self, obj): 42 | self.stack.append(obj) 43 | 44 | def pop(self): 45 | stack = self.stack 46 | if stack: 47 | return stack.pop() 48 | 49 | def clear(self): 50 | del self.stack[:] 51 | 52 | 53 | client_stack = ClientStack() 54 | 55 | # Just expose the bound method, don't wrap it, 56 | # for speed. 57 | statsd_client = client_stack.get 58 | 59 | 60 | def set_statsd_client(client_or_uri): 61 | """ 62 | Set the global StatsdClient. 63 | 64 | The ``client_or_uri`` can be a StatsdClient, a ``statsd://`` URI, 65 | or None. 66 | 67 | Note that when the perfmetrics module is imported, it 68 | looks for the ``STATSD_URI`` environment variable and calls 69 | `set_statsd_client` if that variable is found. 70 | """ 71 | if isinstance(client_or_uri, string_types): 72 | client = statsd_client_from_uri(client_or_uri) 73 | else: 74 | client = client_or_uri 75 | ClientStack.default = client 76 | -------------------------------------------------------------------------------- /src/perfmetrics/interfaces.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Interfaces for perfmetrics. 4 | 5 | If zope.interface is not installed, this file is merely documentation. 6 | 7 | """ 8 | from __future__ import absolute_import 9 | from __future__ import division 10 | from __future__ import print_function 11 | 12 | # pylint:disable=no-self-argument,no-method-argument 13 | 14 | try: 15 | from zope import interface 16 | except ImportError: # pragma: no cover 17 | class Interface(object): 18 | pass 19 | class Attribute(object): 20 | def __init__(self, descr): 21 | self.descr = descr 22 | class implementer(object): 23 | def __init__(self, *ifaces): 24 | pass 25 | def __call__(self, cls): 26 | return cls 27 | else: 28 | Interface = interface.Interface 29 | Attribute = interface.Attribute 30 | implementer = interface.implementer 31 | 32 | 33 | class IStatsdClient(Interface): 34 | """ 35 | Interface to communicate with a StatsD server. 36 | 37 | Most of the methods below have optional ``rate``, ``rate_applied``, 38 | and ``buf`` parameters. The ``rate`` parameter, when set to a value 39 | less than 1, causes StatsdClient to send a random sample of packets rather 40 | than every packet. The ``rate_applied`` parameter, if true, informs 41 | ``StatsdClient`` that the sample rate has already been applied and the 42 | packet should be sent regardless of the specified sample rate. 43 | 44 | If the ``buf`` parameter is a list, StatsdClient 45 | appends the packet contents to the ``buf`` list rather than send the 46 | packet, making it possible to send multiple updates in a single packet. 47 | Keep in mind that the size of UDP packets is limited (the limit varies 48 | by the network, but 1000 bytes is usually a good guess) and any extra 49 | bytes will be ignored silently. 50 | 51 | """ 52 | def close(): 53 | """ 54 | Release resources (sockets) held by this object. 55 | 56 | .. versionadded:: 3.0 57 | """ 58 | 59 | def timing(stat, value, rate=1, buf=None, rate_applied=False): 60 | """ 61 | Log timing information in milliseconds. 62 | 63 | *stat* is the name of the metric to record and *value* is 64 | the timing measurement in milliseconds. Note that Statsd 65 | maintains several data points for each timing metric, so 66 | timing metrics can take more disk space than counters or 67 | gauges. 68 | """ 69 | 70 | def gauge(stat, value, rate=1, buf=None, rate_applied=False): 71 | """ 72 | Update a gauge value. 73 | 74 | *stat* is the name of the metric to record and *value* is 75 | the new gauge value. A gauge represents a persistent value 76 | such as a pool size. Because gauges from different machines 77 | often conflict, a suffix is usually applied to gauge names; 78 | this may be done manually or with `MetricMod`. 79 | """ 80 | 81 | def incr(stat, count=1, rate=1, buf=None, rate_applied=False): 82 | """ 83 | Increment a counter by *count*. 84 | 85 | Note that Statsd clears all counter values every time it sends 86 | the metrics to Graphite, which usually happens every 10 87 | seconds. If you need a persistent value, it may be more 88 | appropriate to use a gauge instead of a counter. 89 | """ 90 | 91 | def decr(stat, count=1, rate=1, buf=None, rate_applied=False): 92 | """ 93 | Decrement a counter. 94 | 95 | This is the opposite of :meth:`incr`. 96 | """ 97 | 98 | def set_add(stat, value, rate=1, buf=None, rate_applied=False): 99 | """ 100 | Add a *value* to the set named by *stat*. 101 | 102 | A StatsD set counts the unique occurrences of events (values) 103 | between flushes. 104 | 105 | For example, if you wanted to count the number of different 106 | users logging in to an application within the sampling period, 107 | you could use something like:: 108 | 109 | def on_login(user_id): 110 | client.set_add("logged_in_users", user_id) 111 | 112 | While this method accepts the *rate* parameter, it may be less 113 | useful here since the point is to let the StatsD server collect 114 | unique events automatically, and it can't do that if some events 115 | are dropped, making it only an estimate. 116 | 117 | .. versionadded:: 3.1.0 118 | """ 119 | 120 | 121 | def sendbuf(buf): 122 | """ 123 | Send a UDP packet containing string lines. 124 | 125 | *buf* is a sequence of strings. 126 | """ 127 | -------------------------------------------------------------------------------- /src/perfmetrics/metric.py: -------------------------------------------------------------------------------- 1 | # cython: auto_pickle=False,embedsignature=True,always_allow_keywords=False 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Implementation of metrics. 5 | 6 | """ 7 | from __future__ import absolute_import 8 | from __future__ import division 9 | from __future__ import print_function 10 | 11 | from time import time 12 | from types import MethodType 13 | from weakref import WeakKeyDictionary 14 | import functools 15 | import random as stdrandom 16 | 17 | from .clientstack import statsd_client 18 | from .clientstack import client_stack as statsd_client_stack 19 | from .statsd import StatsdClientMod 20 | from .statsd import null_client 21 | 22 | logger = __import__('logging').getLogger(__name__) 23 | 24 | class _MethodLikeMixin(object): 25 | __slots__ = () 26 | # We may be wrapped by another decorator, 27 | # so we can't count on __get__ being called. 28 | # But if it is, we need to act like a bound method. 29 | # 30 | # When we compile with Cython, we can't dynamically choose 31 | # a __get__ impl; the last one defined wins, so we must take the conditional 32 | # inside the method. 33 | def __get__(self, inst, klass): 34 | if inst is None: 35 | return self 36 | # Python 2 takes 3 arguments, Python 3 just two. Actually you 37 | # can get away with passing just the first two to Python 2, 38 | # but you get '' and possibly other issues 39 | # (im_class is None), so it's best to pass all three. 40 | return MethodType(self, inst, klass) if str is bytes else MethodType(self, inst) 41 | 42 | class _AbstractMetricImpl(_MethodLikeMixin): 43 | __slots__ = ( 44 | 'f', 45 | 'random', 46 | 'metric_timing', 47 | 'metric_count', 48 | 'metric_rate', 49 | 'timing_format', 50 | '__wrapped__', 51 | '__dict__', 52 | ) 53 | stat_name = None 54 | def __init__(self, f, timing, count, rate, timing_format, random): 55 | self.__wrapped__ = None 56 | self.f = f 57 | self.metric_timing = timing 58 | self.metric_count = count 59 | self.metric_rate = rate 60 | self.timing_format = timing_format 61 | self.random = random 62 | 63 | def __call__(self, *args, **kwargs): 64 | if self.metric_rate < 1 and self.random() >= self.metric_rate: 65 | # Ignore this sample. 66 | return self.f(*args, **kwargs) 67 | 68 | client = statsd_client() 69 | 70 | if client is None: 71 | # No statsd client has been configured. 72 | return self.f(*args, **kwargs) 73 | 74 | stat = self.stat_name or self._compute_stat(args) 75 | # TODO: A lot of this is duplicated with __exit__. 76 | # Can we do better? 77 | if self.metric_timing: 78 | if self.metric_count: 79 | buf = [] 80 | client.incr(stat, 1, self.metric_rate, buf=buf, rate_applied=True) 81 | else: 82 | buf = None 83 | 84 | start = time() 85 | 86 | try: 87 | return self.f(*args, **kwargs) 88 | finally: 89 | end = time() 90 | elapsed_ms = int((end - start) * 1000.0) 91 | client.timing(self.timing_format % stat, elapsed_ms, 92 | self.metric_rate, buf=buf, rate_applied=True) 93 | if buf: 94 | client.sendbuf(buf) 95 | 96 | else: 97 | if self.metric_count: 98 | client.incr(stat, 1, self.metric_rate, rate_applied=True) 99 | return self.f(*args, **kwargs) 100 | 101 | def _compute_stat(self, args): 102 | raise NotImplementedError 103 | 104 | class _GivenStatMetricImpl(_AbstractMetricImpl): 105 | __slots__ = ( 106 | 'stat_name', 107 | ) 108 | def __init__(self, stat_name, *args): 109 | self.stat_name = stat_name 110 | super(_GivenStatMetricImpl, self).__init__(*args) 111 | 112 | def _compute_stat(self, args): # pragma: no cover 113 | return self.stat_name 114 | 115 | class _MethodMetricImpl(_AbstractMetricImpl): 116 | __slots__ = ( 117 | 'klass_dict', 118 | ) 119 | 120 | def __init__(self, *args): 121 | self.klass_dict = WeakKeyDictionary() 122 | super(_MethodMetricImpl, self).__init__(*args) 123 | 124 | def _compute_stat(self, args): 125 | klass = args[0].__class__ 126 | try: 127 | stat_name = self.klass_dict[klass] 128 | except KeyError: 129 | stat_name = '%s.%s.%s' % (klass.__module__, klass.__name__, self.f.__name__) 130 | self.klass_dict[klass] = stat_name 131 | return stat_name 132 | 133 | 134 | class Metric(object): 135 | """ 136 | Metric(stat=None, rate=1, method=False, count=True, timing=True) 137 | 138 | A decorator or context manager with options. 139 | 140 | ``stat`` is the name of the metric to send; set it to None to use 141 | the name of the function or method. ``rate`` lets you reduce the 142 | number of packets sent to Statsd by selecting a random sample; for 143 | example, set it to 0.1 to send one tenth of the packets. If the 144 | ``method`` parameter is true, the default metric name is based on 145 | the method's class name rather than the module name. Setting 146 | ``count`` to False disables the counter statistics sent to Statsd. 147 | Setting ``timing`` to False disables the timing statistics sent to 148 | Statsd. 149 | 150 | Sample use as a decorator:: 151 | 152 | @Metric('frequent_func', rate=0.1, timing=False) 153 | def frequent_func(): 154 | "Do something fast and frequently." 155 | 156 | Sample use as a context manager:: 157 | 158 | def do_something(): 159 | with Metric('doing_something'): 160 | pass 161 | 162 | If perfmetrics sends packets too frequently, UDP packets may be lost 163 | and the application performance may be affected. You can reduce 164 | the number of packets and the CPU overhead using the ``Metric`` 165 | decorator with options instead of `metric` or `metricmethod`. 166 | The decorator example above uses a sample rate and a static metric name. 167 | It also disables the collection of timing information. 168 | 169 | When using Metric as a context manager, you must provide the 170 | ``stat`` parameter or nothing will be recorded. 171 | 172 | .. versionchanged:: 3.0 173 | 174 | When used as a decorator, set ``__wrapped__`` on the returned object, even 175 | on Python 2. 176 | 177 | .. versionchanged:: 3.0 178 | 179 | When used as a decorator, the returned object 180 | has ``metric_timing``, ``metric_count`` and ``metric_rate`` 181 | attributes that can be changed to alter its behaviour. 182 | 183 | """ 184 | 185 | def __init__(self, stat=None, rate=1, method=False, 186 | count=True, timing=True, timing_format='%s.t', 187 | random=stdrandom.random): # testing hook 188 | self.stat = stat 189 | self.rate = rate 190 | self.method = method 191 | self.count = count 192 | self.timing = timing 193 | self.timing_format = timing_format 194 | self.random = random 195 | self.start = 0.0 196 | 197 | def __call__(self, f): 198 | """ 199 | Decorate a function or method so it can send statistics to statsd. 200 | """ 201 | func_name = f.__name__ 202 | func_full_name = '%s.%s' % (f.__module__, func_name) 203 | 204 | if self.method: 205 | metric = _MethodMetricImpl(f, self.timing, self.count, 206 | self.rate, self.timing_format, 207 | self.random) 208 | else: 209 | metric = _GivenStatMetricImpl( 210 | self.stat or func_full_name, 211 | f, self.timing, self.count, 212 | self.rate, self.timing_format, 213 | self.random) 214 | 215 | metric = functools.update_wrapper(metric, f) 216 | metric.__wrapped__ = f # Python 2 doesn't set this, but it's handy to have. 217 | return metric 218 | 219 | # Metric can also be used as a context manager. 220 | 221 | def __enter__(self): 222 | self.start = time() 223 | 224 | def __exit__(self, _typ, _value, _tb): 225 | rate = self.rate 226 | if rate < 1 and self.random() >= rate: 227 | # Ignore this sample. 228 | return 229 | 230 | client = statsd_client_stack.get() 231 | if client is not None: 232 | buf = [] 233 | stat = self.stat 234 | if stat: 235 | if self.count: 236 | client.incr(stat, rate=rate, buf=buf, rate_applied=True) 237 | if self.timing: 238 | elapsed = int((time() - self.start) * 1000.0) 239 | client.timing(self.timing_format % stat, elapsed, 240 | rate=rate, buf=buf, rate_applied=True) 241 | if buf: 242 | client.sendbuf(buf) 243 | 244 | class MetricMod(object): 245 | """Decorator/context manager that modifies the name of metrics in context. 246 | 247 | format is a format string such as 'XYZ.%s'. 248 | """ 249 | 250 | def __init__(self, format): 251 | self.format = format 252 | 253 | def __call__(self, f): 254 | """Decorate a function or method to add a metric prefix in context. 255 | """ 256 | 257 | @functools.wraps(f) 258 | def call_with_mod(*args, **kw): 259 | client = statsd_client_stack.get() 260 | if client is None: 261 | # Statsd is not configured. 262 | return f(*args, **kw) 263 | 264 | statsd_client_stack.push(StatsdClientMod(client, self.format)) 265 | try: 266 | return f(*args, **kw) 267 | finally: 268 | statsd_client_stack.pop() 269 | 270 | call_with_mod.__wrapped__ = f 271 | return call_with_mod 272 | 273 | def __enter__(self): 274 | client = statsd_client_stack.get() 275 | 276 | if client is None: 277 | statsd_client_stack.push(null_client) 278 | else: 279 | statsd_client_stack.push(StatsdClientMod(client, self.format)) 280 | 281 | def __exit__(self, _typ, _value, _tb): 282 | statsd_client_stack.pop() 283 | 284 | # pylint:disable=wrong-import-position,wrong-import-order 285 | from perfmetrics._util import import_c_accel 286 | import_c_accel(globals(), 'perfmetrics._metric') 287 | -------------------------------------------------------------------------------- /src/perfmetrics/pyramid.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Optional pyramid integration. 4 | 5 | """ 6 | from __future__ import absolute_import 7 | from __future__ import division 8 | from __future__ import print_function 9 | 10 | from .statsd import statsd_client_from_uri 11 | from .clientstack import client_stack as statsd_client_stack 12 | from .metric import Metric 13 | 14 | logger = __import__('logging').getLogger(__name__) 15 | 16 | def includeme(config): 17 | """Pyramid configuration hook: activate the perfmetrics tween. 18 | 19 | A statsd_uri should be in the settings. 20 | """ 21 | if config.registry.settings.get('statsd_uri'): 22 | config.add_tween('perfmetrics.tween') 23 | 24 | 25 | def tween(handler, registry): 26 | """Pyramid tween that sets up a Statsd client for each request. 27 | """ 28 | uri = registry.settings['statsd_uri'] 29 | client = statsd_client_from_uri(uri) 30 | 31 | handler = Metric('perfmetrics.tween')(handler) 32 | def handle(request): 33 | statsd_client_stack.push(client) 34 | try: 35 | return handler(request) 36 | finally: 37 | statsd_client_stack.pop() 38 | 39 | return handle 40 | -------------------------------------------------------------------------------- /src/perfmetrics/statsd.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Statsd client implementations. 4 | 5 | """ 6 | from __future__ import absolute_import 7 | from __future__ import division 8 | from __future__ import print_function 9 | 10 | import logging 11 | import random 12 | import socket 13 | 14 | try: 15 | # Python 3 16 | from urllib.parse import urlsplit 17 | from urllib.parse import parse_qsl 18 | from urllib.parse import uses_query 19 | 20 | basestring = str 21 | 22 | except ImportError: # pragma: no cover 23 | # Python 2 24 | from urlparse import urlsplit 25 | from urlparse import parse_qsl 26 | from urlparse import uses_query 27 | 28 | from .interfaces import IStatsdClient 29 | from .interfaces import implementer 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | __all__ = [ 34 | 'StatsdClient', 35 | 'StatsdClientMod', 36 | 'NullStatsdClient', 37 | 'statsd_client_from_uri', 38 | ] 39 | 40 | if 'statsd' not in uses_query: # pragma: no cover 41 | uses_query.append('statsd') 42 | 43 | def statsd_client_from_uri(uri): 44 | """ 45 | Create and return :class:`perfmetrics.statsd.StatsdClient`. 46 | 47 | A typical URI is ``statsd://localhost:8125``. An optional query 48 | parameter is ``prefix``. The default prefix is an empty string. 49 | 50 | """ 51 | parts = urlsplit(uri) 52 | if parts.scheme != 'statsd': 53 | raise ValueError("URI scheme not supported: %s" % uri) 54 | 55 | kw = {} 56 | if parts.query: 57 | kw.update(parse_qsl(parts.query)) 58 | return StatsdClient(parts.hostname, parts.port, **kw) 59 | 60 | 61 | @implementer(IStatsdClient) 62 | class StatsdClient(object): 63 | """ 64 | Send packets to statsd. 65 | 66 | Default implementation of :class:`perfmetrics.interfaces.IStatsdClient`. 67 | 68 | Derived from statsd.py by Steve Ivy . 69 | """ 70 | 71 | def __init__(self, host='localhost', port=8125, prefix=''): 72 | # Resolve the host name early. 73 | info = socket.getaddrinfo(host, int(port), 0, socket.SOCK_DGRAM) 74 | family, socktype, proto, _canonname, addr = info[0] 75 | self.addr = addr 76 | self.log = logger 77 | self.udp_sock = socket.socket(family, socktype, proto) 78 | self.random = random.random # Testing hook 79 | if prefix and not prefix.endswith('.'): 80 | prefix = prefix + '.' 81 | self.prefix = prefix 82 | 83 | def close(self): 84 | """ 85 | See :meth:`perfmetrics.interfaces.IStatsdClient.close`. 86 | 87 | .. versionadded:: 3.0 88 | """ 89 | if self.udp_sock: 90 | self.udp_sock.close() 91 | self.udp_sock = None 92 | 93 | def timing(self, stat, value, rate=1, buf=None, rate_applied=False): 94 | """ 95 | See :meth:`perfmetrics.interfaces.IStatsdClient.timing`. 96 | 97 | """ 98 | if rate >= 1 or rate_applied or self.random() < rate: 99 | s = '%s%s:%d|ms' % (self.prefix, stat, value) 100 | if buf is None: 101 | self._send(s) 102 | else: 103 | buf.append(s) 104 | 105 | def gauge(self, stat, value, rate=1, buf=None, rate_applied=False): 106 | """ 107 | See :meth:`perfmetrics.interfaces.IStatsdClient.gauge`. 108 | """ 109 | if rate >= 1 or rate_applied or self.random() < rate: 110 | s = '%s%s:%s|g' % (self.prefix, stat, value) 111 | if buf is None: 112 | self._send(s) 113 | else: 114 | buf.append(s) 115 | 116 | def incr(self, stat, count=1, rate=1, buf=None, rate_applied=False): 117 | """ 118 | See :meth:`perfmetrics.interfaces.IStatsdClient.incr`. 119 | """ 120 | if rate >= 1: 121 | s = '%s%s:%s|c' % (self.prefix, stat, count) 122 | elif rate_applied or self.random() < rate: 123 | s = '%s%s:%s|c|@%s' % (self.prefix, stat, count, rate) 124 | else: 125 | return 126 | 127 | if buf is None: 128 | self._send(s) 129 | else: 130 | buf.append(s) 131 | 132 | def decr(self, stat, count=1, rate=1, buf=None, rate_applied=False): 133 | """ 134 | See :meth:`perfmetrics.interfaces.IStatsdClient.decr`. 135 | """ 136 | self.incr(stat, -count, rate=rate, buf=buf, rate_applied=rate_applied) 137 | 138 | def set_add(self, stat, value, rate=1, buf=None, rate_applied=False): 139 | """ 140 | See :meth:`perfmetrics.interfaces.IStatsdClient.set_add`. 141 | """ 142 | if rate >= 1 or rate_applied or self.random() < rate: 143 | s = '%s%s:%s|s' % (self.prefix, stat, value) 144 | if buf is None: 145 | self._send(s) 146 | else: 147 | buf.append(s) 148 | 149 | def _send(self, data): 150 | """Send a UDP packet containing a string.""" 151 | try: 152 | self.udp_sock.sendto(data.encode('ascii'), self.addr) 153 | except IOError: 154 | self.log.exception("Failed to send UDP packet") 155 | 156 | def sendbuf(self, buf): 157 | """ 158 | See :meth:`perfmetrics.interfaces.IStatsdClient.sendbuf`. 159 | """ 160 | if buf: 161 | self._send('\n'.join(buf)) 162 | 163 | 164 | @implementer(IStatsdClient) 165 | class StatsdClientMod(object): 166 | """ 167 | Wrap `StatsdClient`, modifying all stat names in context. 168 | 169 | .. versionchanged:: 3.0 170 | 171 | The wrapped object's attributes are now accessible on this object. 172 | 173 | This object now uses ``__slots__``. 174 | """ 175 | 176 | __slots__ = ( 177 | '_wrapped', 178 | 'format', 179 | ) 180 | 181 | def __init__(self, wrapped, format): 182 | self._wrapped = wrapped 183 | self.format = format 184 | 185 | def close(self): 186 | self._wrapped.close() 187 | 188 | def __getattr__(self, name): 189 | return getattr(self._wrapped, name) 190 | 191 | def __setattr__(self, name, value): 192 | if name in self.__slots__: 193 | object.__setattr__(self, name, value) 194 | else: 195 | setattr(self._wrapped, name, value) 196 | 197 | def timing(self, stat, *args, **kw): 198 | self._wrapped.timing(self.format % stat, *args, **kw) 199 | 200 | def gauge(self, stat, *args, **kw): 201 | self._wrapped.gauge(self.format % stat, *args, **kw) 202 | 203 | def incr(self, stat, *args, **kw): 204 | self._wrapped.incr(self.format % stat, *args, **kw) 205 | 206 | def decr(self, stat, *args, **kw): 207 | self._wrapped.decr(self.format % stat, *args, **kw) 208 | 209 | def set_add(self, stat, *args, **kw): 210 | self._wrapped.set_add(self.format % stat, *args, **kw) 211 | 212 | def sendbuf(self, buf): 213 | self._wrapped.sendbuf(buf) 214 | 215 | 216 | @implementer(IStatsdClient) 217 | class NullStatsdClient(object): 218 | """No-op statsd client.""" 219 | 220 | def close(self): 221 | """Does nothing.""" 222 | 223 | def timing(self, stat, *args, **kw): 224 | """Does nothing.""" 225 | 226 | def gauge(self, stat, *args, **kw): 227 | """Does nothing.""" 228 | 229 | def incr(self, stat, *args, **kw): 230 | """Does nothing.""" 231 | 232 | def decr(self, stat, *args, **kw): 233 | """Does nothing.""" 234 | 235 | def set_add(self, stat, value, *args, **kw): 236 | """Does nothing.""" 237 | 238 | def sendbuf(self, buf): 239 | """Does nothing""" 240 | 241 | 242 | null_client = NullStatsdClient() 243 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Support for testing that code is instrumented as expected. 5 | """ 6 | from __future__ import print_function, absolute_import, division 7 | __docformat__ = "restructuredtext en" 8 | 9 | __all__ = [ 10 | 'FakeStatsDClient', 11 | 'Observation', 12 | 'OBSERVATION_KIND_COUNTER', 13 | 'OBSERVATION_KIND_GAUGE', 14 | 'OBSERVATION_KIND_SET', 15 | 'OBSERVATION_KIND_TIMER', 16 | ] 17 | 18 | from .client import FakeStatsDClient 19 | from .observation import Observation 20 | from .observation import OBSERVATION_KIND_COUNTER 21 | from .observation import OBSERVATION_KIND_GAUGE 22 | from .observation import OBSERVATION_KIND_SET 23 | from .observation import OBSERVATION_KIND_TIMER 24 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function, absolute_import, division 5 | __docformat__ = "restructuredtext en" 6 | 7 | 8 | from perfmetrics.statsd import StatsdClient 9 | 10 | from .observation import Observation 11 | 12 | 13 | class _TrackingSocket(object): 14 | """ 15 | Maintain a list of sent packet/address pairs, as well 16 | as parsed observations/address pairs. 17 | """ 18 | def __init__(self): 19 | self.sent_packets = [] # type: List[Tuple[str, bytes] 20 | self.observations = [] # type: List[Tuple[Observation, bytes] 21 | 22 | def clear(self): 23 | del self.sent_packets[:] 24 | del self.observations[:] 25 | 26 | close = clear 27 | 28 | def sendto(self, data, addr): 29 | # The client encoded to bytes 30 | assert isinstance(data, bytes) 31 | # We always want native strings here, that's what the 32 | # user specified when calling the StatsdClient methods. 33 | data = data.decode('utf-8') if bytes is not str else data 34 | self.sent_packets.append((data, addr,)) 35 | for m in Observation.make_all(data): 36 | self.observations.append((m, addr,)) 37 | 38 | 39 | class FakeStatsDClient(StatsdClient): 40 | """ 41 | A mock statsd client that tracks sent statsd metrics in memory 42 | rather than pushing them over a socket. This class is a drop 43 | in replacement for `perfmetrics.statsd.StatsdClient` that collects statsd 44 | packets and `~.Observation` that are sent through the client. 45 | 46 | .. versionchanged:: 3.1.0 47 | Like the normal clients, this object is now always true, whether or 48 | not any observations have been sent. 49 | """ 50 | 51 | def __init__(self, prefix=''): 52 | """ 53 | Create a mock statsd client with the given prefix. 54 | """ 55 | super(FakeStatsDClient, self).__init__(prefix=prefix) 56 | 57 | # Monkey patch the socket to track things in memory instead 58 | self.udp_sock.close() 59 | self.udp_sock = _TrackingSocket() 60 | 61 | def clear(self): 62 | """ 63 | Clears the statsd metrics that have been collected 64 | """ 65 | self.udp_sock.clear() 66 | 67 | def __bool__(self): 68 | return True 69 | 70 | __nonzero__ = __bool__ # Python 2 71 | 72 | def __len__(self): 73 | """ 74 | The number of metrics sent. This accounts for multi metric packets 75 | that may be sent. 76 | """ 77 | return len(self.udp_sock.observations) 78 | 79 | def __iter__(self): 80 | """ 81 | Iterates the `Observations <~.Observation>` provided to this statsd 82 | client. 83 | """ 84 | for metric, _ in self.udp_sock.observations: 85 | yield metric 86 | iter_observations = __iter__ 87 | 88 | def iter_packets(self): 89 | """ 90 | Iterates the raw statsd packets provided to the statsd client. 91 | 92 | :return: Iterator of native strings. 93 | """ 94 | for data, _ in self.udp_sock.sent_packets: 95 | yield data 96 | 97 | @property 98 | def observations(self): 99 | """ 100 | A list of `~.Observation` objects collected by this client. 101 | 102 | .. seealso:: `iter_observations` 103 | """ 104 | return list(self) 105 | 106 | @property 107 | def packets(self): 108 | """ 109 | A list of raw statsd packets collected by this client. 110 | 111 | .. seealso:: `iter_packets` 112 | """ 113 | return list(self.iter_packets()) 114 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/matchers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function, absolute_import, division 5 | __docformat__ = "restructuredtext en" 6 | 7 | from hamcrest.core.matcher import Matcher 8 | from hamcrest.core.base_matcher import BaseMatcher 9 | 10 | from hamcrest import all_of 11 | from hamcrest import instance_of 12 | from hamcrest import is_ 13 | from hamcrest import has_properties 14 | from hamcrest import none 15 | 16 | 17 | from .observation import OBSERVATION_KIND_COUNTER as METRIC_COUNTER_KIND 18 | from .observation import OBSERVATION_KIND_GAUGE as METRIC_GAUGE_KIND 19 | from .observation import OBSERVATION_KIND_SET as METRIC_SET_KIND 20 | from .observation import OBSERVATION_KIND_TIMER as METRIC_TIMER_KIND 21 | 22 | from .observation import Observation 23 | 24 | __all__ = [ 25 | 'is_observation', 26 | 'is_counter', 27 | 'is_gauge', 28 | 'is_set', 29 | 'is_timer', 30 | ] 31 | 32 | _marker = object() 33 | 34 | _metric_kind_display_name = { 35 | 'c': 'counter', 36 | 'g': 'gauge', 37 | 'ms': 'timer', 38 | 's': 'set' 39 | } 40 | 41 | 42 | class IsMetric(BaseMatcher): 43 | 44 | # See _matches() 45 | _force_one_description = False 46 | 47 | def __init__(self, kwargs): 48 | matchers = {} 49 | for key, value in kwargs.items(): 50 | if value is None: 51 | value = none() 52 | elif key == 'sampling_rate': 53 | # This one is special, it doesn't get 54 | # to be a string, it's kept as a number. 55 | value = is_(value) 56 | elif not isinstance(value, Matcher): 57 | value = str(value) 58 | matchers[key] = value 59 | 60 | # Beginning in 1.10 and up through at least 2.0.2, 61 | # has_properties con no longer be called with an empty dictionary 62 | # without creating a KeyError from popitem(). 63 | self._matcher = all_of( 64 | instance_of(Observation), 65 | has_properties(**matchers) if matchers else instance_of(Observation), 66 | ) 67 | if self._force_one_description: 68 | self.__patch_matcher() 69 | 70 | def __patch_matcher(self): 71 | # This is tightly coupled to the structure of the matcher we create. 72 | self._matcher.describe_all_mismatches = False 73 | has_prop_matcher = self._matcher.matchers[1] 74 | if hasattr(has_prop_matcher, 'matcher'): 75 | has_prop_matcher.matcher.describe_all_mismatches = False 76 | 77 | def _matches(self, item): 78 | # On PyHamcrest == 1.10.x (last release for Python 2) 79 | # There's a bug such that you can't call AllOf.matches without 80 | # passing in a description without getting 81 | # ``AttributeError: None has no append_text`` 82 | # So we pass one, even though it gets thrown away. 83 | # XXX: Remove this workaround when we only support PyHamcrest 2. 84 | # See https://github.com/hamcrest/PyHamcrest/issues/130 85 | try: 86 | return self._matcher.matches(item) 87 | except AttributeError as ex: 88 | if 'append_text' not in str(ex): # pragma: no cover 89 | raise 90 | IsMetric._force_one_description = True 91 | self.__patch_matcher() 92 | return self._matcher.matches(item) 93 | 94 | def describe_to(self, description): 95 | self._matcher.describe_to(description) 96 | 97 | def describe_mismatch(self, item, mismatch_description): 98 | mismatch_description.append_text('was ').append_text(repr(item)) 99 | 100 | def _is_metric(args, kwargs): 101 | for arg_ix, name in enumerate(( 102 | 'kind', 103 | 'name', 104 | 'value', 105 | 'sampling_rate' 106 | )): 107 | if name in kwargs or len(args) <= arg_ix: 108 | continue 109 | kwargs[name] = args[arg_ix] 110 | 111 | return IsMetric(kwargs) 112 | 113 | 114 | def is_observation(*args, **kwargs): 115 | """ 116 | is_observation(*, kind, name, value, sampling_rate) -> matcher 117 | 118 | A hamcrest matcher that validates the specific parts of a `~.Observation`. 119 | All arguments are optional and can be provided by name or position. 120 | 121 | :keyword str kind: A hamcrest matcher or string that matches the kind for this metric 122 | :keyword str name: A hamcrest matcher or string that matches the name for this metric 123 | :keyword str value: A hamcrest matcher or string that matches the value for this metric 124 | :keyword float sampling_rate: A hamcrest matcher or 125 | number that matches the sampling rate this metric was collected with 126 | """ 127 | return _is_metric(args, kwargs) 128 | 129 | 130 | def is_counter(*args, **kwargs): 131 | """ 132 | is_counter(*, name, value, sampling_rate) -> matcher 133 | 134 | A hamcrest matcher validating the parts of a counter `~.Observation`. 135 | 136 | .. seealso:: `is_metric` 137 | """ 138 | kwargs['kind'] = METRIC_COUNTER_KIND 139 | args = (None,) + args 140 | return _is_metric(args, kwargs) 141 | 142 | 143 | def is_gauge(*args, **kwargs): 144 | """ 145 | is_gauge(*, name, value, sampling_rate) -> matcher 146 | 147 | A hamcrest matcher validating the parts of a gauge `~.Observation` 148 | 149 | .. seealso:: `is_metric` 150 | """ 151 | kwargs['kind'] = METRIC_GAUGE_KIND 152 | args = (None,) + args 153 | return _is_metric(args, kwargs) 154 | 155 | 156 | def is_timer(*args, **kwargs): 157 | """ 158 | is_timer(*, name, value, sampling_rate) -> matcher 159 | 160 | A hamcrest matcher validating the parts of a timer `~.Observation` 161 | 162 | .. seealso:: `is_metric` 163 | """ 164 | kwargs['kind'] = METRIC_TIMER_KIND 165 | args = (None,) + args 166 | return _is_metric(args, kwargs) 167 | 168 | def is_set(*args, **kwargs): 169 | """ 170 | is_set(*, name, value, sampling_rate) -> matcher 171 | 172 | A hamcrest matcher validating the parts of a set `~.Observation` 173 | 174 | .. seealso:: `is_metric` 175 | """ 176 | kwargs['kind'] = METRIC_SET_KIND 177 | args = (None,) + args 178 | return _is_metric(args, kwargs) 179 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/observation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function, absolute_import, division 5 | __docformat__ = "restructuredtext en" 6 | 7 | #: The statsd metric kind for Gauges 8 | OBSERVATION_KIND_GAUGE = 'g' 9 | 10 | #: The statsd metric kind for Counters 11 | OBSERVATION_KIND_COUNTER = 'c' 12 | 13 | #: The statsd metric kind for Sets 14 | OBSERVATION_KIND_SET = 's' 15 | 16 | #: The statsd metric kind for Timers 17 | OBSERVATION_KIND_TIMER = 'ms' 18 | 19 | 20 | def _parse_sampling_data(data): 21 | """ 22 | Parses sampling rate from the provided packet part *data*. 23 | 24 | Raises a `ValueError` if the packet part is invalid sampling data 25 | """ 26 | if not data.startswith('@'): 27 | raise ValueError('Expected "@" in sampling data. %s' % data) 28 | return float(data[1:]) 29 | 30 | 31 | def _as_metric(metric_data): 32 | """ 33 | Parses a single metric packet, *metric_data*, in to a `Metric`. 34 | 35 | Metrics take the form of ``:|(|@)`` 36 | 37 | A `ValueError` is raised for invalid data 38 | """ 39 | 40 | sampling = None 41 | name = None 42 | value = None 43 | kind = None 44 | parts = metric_data.split('|') 45 | if len(parts) < 2 or len(parts) > 3: 46 | raise ValueError('Unexpected metric data %s. Wrong number of parts' % metric_data) 47 | 48 | if len(parts) == 3: 49 | sampling_data = parts.pop(-1) 50 | sampling = _parse_sampling_data(sampling_data) 51 | 52 | kind = parts[1] 53 | name, value = parts[0].split(':') 54 | 55 | return Observation(name, value, kind, sampling_rate=sampling) 56 | 57 | 58 | def _as_metrics(data): 59 | """ 60 | Parses the statsd *data* packet to a _list_ of metrics. 61 | 62 | .. seealso:: https://github.com/etsy/statsd/blob/master/docs/metric_types.md 63 | """ 64 | metrics = [] 65 | 66 | # Multi metric packets are seperated by newlines 67 | for metric_data in data.split('\n'): 68 | metrics.append(_as_metric(metric_data)) 69 | return metrics 70 | 71 | 72 | class Observation(object): 73 | """ 74 | The representation of a single statsd metric. 75 | """ 76 | 77 | #: The metric name 78 | name = None 79 | 80 | #: The value provided for the metric 81 | value = None 82 | 83 | #: The statsd code for the type of metric. e.g. one of the ``METRIC_*_KIND`` constants 84 | kind = None 85 | 86 | #: The rate with which this event has been sampled from (optional) 87 | sampling_rate = None 88 | 89 | def __init__(self, name, value, kind, sampling_rate=None): 90 | self.name = name 91 | self.value = value 92 | self.sampling_rate = sampling_rate 93 | self.kind = kind 94 | 95 | @classmethod 96 | def make(cls, packet): 97 | """ 98 | Creates a metric from the provided statsd *packet*. 99 | 100 | :raises ValueError: if *packet* is a multi metric packet or 101 | otherwise invalid. 102 | """ 103 | metrics = cls.make_all(packet) 104 | if len(metrics) != 1: 105 | raise ValueError('Must supply a single metric packet. %s supplied' % packet) 106 | return metrics[0] 107 | 108 | @classmethod 109 | def make_all(cls, packet): 110 | """ 111 | Makes a list of metrics from the provided statsd *packet*. 112 | 113 | Like `make` but supports multi metric packets 114 | """ 115 | return _as_metrics(packet) 116 | 117 | def __str__(self): 118 | sampling_string = '|@%g' % self.sampling_rate if self.sampling_rate is not None else '' 119 | return '%s:%s|%s%s' % (self.name, self.value, self.kind, sampling_string) 120 | 121 | def __repr__(self): 122 | return "%s(name=%r, value=%r, kind=%r, sampling_rate=%r)" % ( 123 | self.__class__.__name__, 124 | self.name, self.value, self.kind, self.sampling_rate 125 | ) 126 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/tests/test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function, absolute_import, division 5 | __docformat__ = "restructuredtext en" 6 | 7 | # disable: accessing protected members, too many methods 8 | # pylint: disable=W0212,R0904 9 | 10 | import doctest 11 | import os 12 | import unittest 13 | 14 | from hamcrest import assert_that 15 | from hamcrest import contains_exactly as contains 16 | 17 | from hamcrest import has_length 18 | from hamcrest import has_properties 19 | from hamcrest import has_property 20 | 21 | from ...tests import is_true 22 | 23 | from ...tests.test_statsd import TestBasics 24 | 25 | from .. import FakeStatsDClient as MockStatsDClient 26 | 27 | from ..observation import OBSERVATION_KIND_COUNTER as METRIC_COUNTER_KIND 28 | from ..observation import OBSERVATION_KIND_GAUGE as METRIC_GAUGE_KIND 29 | from ..observation import OBSERVATION_KIND_SET as METRIC_SET_KIND 30 | from ..observation import OBSERVATION_KIND_TIMER as METRIC_TIMER_KIND 31 | 32 | class TestMockStatsDClient(TestBasics): 33 | 34 | _class = MockStatsDClient 35 | 36 | def setUp(self): 37 | self.client = self._makeOne() 38 | 39 | def test_true_initially(self): 40 | assert_that(self.client, is_true()) 41 | 42 | def test_tracks_metrics(self): 43 | self.client.incr('mycounter') 44 | self.client.gauge('mygauge', 5) 45 | self.client.timing('mytimer', 3003) 46 | self.client.set_add('myset', 42) 47 | 48 | assert_that(self.client, has_length(4)) 49 | 50 | counter, gauge, timer, Set = self.client.observations 51 | 52 | assert_that(counter, has_properties('name', 'mycounter', 53 | 'value', '1', 54 | 'kind', METRIC_COUNTER_KIND)) 55 | 56 | assert_that(gauge, has_properties('name', 'mygauge', 57 | 'value', '5', 58 | 'kind', METRIC_GAUGE_KIND)) 59 | 60 | assert_that(timer, has_properties( 61 | 'name', 'mytimer', 62 | 'value', '3003', 63 | 'kind', METRIC_TIMER_KIND 64 | )) 65 | 66 | assert_that(Set, has_properties( 67 | 'name', 'myset', 68 | 'value', '42', 69 | 'kind', METRIC_SET_KIND 70 | )) 71 | 72 | def test_clear(self): 73 | self.client.incr('mycounter') 74 | assert_that(self.client, has_length(1)) 75 | assert_that(self.client, is_true()) 76 | 77 | self.client.clear() 78 | assert_that(self.client, has_length(0)) 79 | assert_that(self.client, is_true()) 80 | 81 | def test_tracks_multimetrics(self): 82 | packet = 'gorets:1|c\nglork:320|ms\ngaugor:333|g\nuniques:765|s' 83 | self.client._send(packet) 84 | 85 | assert_that(self.client, has_length(4)) 86 | assert_that(self.client.packets, contains(packet)) 87 | 88 | assert_that(self.client.observations, 89 | contains(has_property('kind', METRIC_COUNTER_KIND), 90 | has_property('kind', METRIC_TIMER_KIND), 91 | has_property('kind', METRIC_GAUGE_KIND), 92 | has_property('kind', METRIC_SET_KIND))) 93 | 94 | def test_suite(): 95 | root = this_dir = os.path.dirname(os.path.abspath(__file__)) 96 | while not os.path.exists(os.path.join(root, 'setup.py')): 97 | prev, root = root, os.path.dirname(root) 98 | if root == prev: 99 | # We seem to be installed out of tree. Are we working in the 100 | # right directory at least? 101 | if os.path.exists('setup.py'): # pragma: no cover 102 | root = os.path.dirname(os.path.abspath('setup.py')) 103 | break 104 | # Let's avoid infinite loops at root 105 | raise AssertionError('could not find my setup.py') 106 | docs = os.path.join(root, 'docs') 107 | testing_rst = os.path.join(docs, 'testing.rst') 108 | 109 | optionflags = ( 110 | doctest.NORMALIZE_WHITESPACE 111 | | doctest.ELLIPSIS 112 | | doctest.IGNORE_EXCEPTION_DETAIL 113 | ) 114 | 115 | # Can't pass absolute paths to DocFileSuite, needs to be 116 | # module relative 117 | testing_rst = os.path.relpath(testing_rst, this_dir) 118 | 119 | return unittest.TestSuite(( 120 | unittest.defaultTestLoader.loadTestsFromName(__name__), 121 | doctest.DocFileSuite( 122 | testing_rst, 123 | optionflags=optionflags 124 | ), 125 | )) 126 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/tests/test_matchers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function, absolute_import, division 5 | __docformat__ = "restructuredtext en" 6 | 7 | 8 | import unittest 9 | 10 | from hamcrest import all_of 11 | from hamcrest import assert_that 12 | from hamcrest import contains_string 13 | from hamcrest import is_not 14 | from hamcrest import none 15 | 16 | from hamcrest.core.string_description import StringDescription 17 | 18 | from ..matchers import is_observation as is_metric 19 | from ..matchers import is_counter 20 | from ..matchers import is_gauge 21 | from ..matchers import is_set 22 | from ..matchers import is_timer 23 | 24 | from ..observation import Observation as Metric 25 | 26 | 27 | class TestIsMetric(unittest.TestCase): 28 | 29 | def setUp(self): 30 | self.counter = Metric.make('foo:1|c') 31 | self.timer = Metric.make('foo:100|ms|@0.1') 32 | self.set = Metric.make('foo:bar|s') 33 | self.gauge = Metric.make('foo:200|g') 34 | 35 | def test_is_metric(self): 36 | assert_that(self.counter, is_metric('c')) 37 | assert_that(self.counter, is_metric('c', 'foo')) 38 | assert_that(self.counter, is_metric('c', 'foo', '1')) 39 | assert_that(self.counter, is_metric('c', 'foo', '1', None)) 40 | 41 | def test_non_metric(self): 42 | assert_that(object(), is_not(is_metric())) 43 | 44 | def test_is_counter(self): 45 | assert_that(self.counter, is_counter()) 46 | 47 | def test_is_gauge(self): 48 | assert_that(self.gauge, is_gauge()) 49 | 50 | def test_is_set(self): 51 | assert_that(self.set, is_set()) 52 | 53 | def test_is_timer(self): 54 | assert_that(self.timer, is_timer()) 55 | 56 | def test_bad_kind(self): 57 | assert_that(self.counter, is_not(is_timer())) 58 | 59 | def test_bad_name(self): 60 | assert_that(self.counter, is_not(is_counter('bar'))) 61 | 62 | def test_bad_value(self): 63 | assert_that(self.counter, is_not(is_counter('foo', '2'))) 64 | 65 | def test_bad_sampling(self): 66 | assert_that(self.timer, is_not(is_counter('foo', '100', None))) 67 | 68 | def test_failure_error(self): 69 | desc = StringDescription() 70 | matcher = is_counter('foo', '1', 0.1) 71 | matcher.describe_to(desc) 72 | desc = str(desc) 73 | # Strip internal newlines, which vary between 74 | # hamcrest versions. Also, the exact text varies too; 75 | # beginning with 1.10, the has_properties matcher we use internally is 76 | # less verbose by default and so we get a string like: 77 | # an object with properties kind matching c and name matching foo 78 | # where before it was something like 79 | # an object with property kind matching c 80 | # and an object with property name matching foo 81 | 82 | desc = desc.replace('\n', '') 83 | assert_that( 84 | desc, 85 | all_of( 86 | contains_string( 87 | "(an instance of Observation and "), 88 | contains_string( 89 | "'kind' matching 'c'"), 90 | contains_string( 91 | "'name' matching 'foo'"), 92 | contains_string( 93 | "'value' matching '1'"), 94 | contains_string( 95 | "'sampling_rate' matching <0.1>" 96 | ) 97 | ) 98 | ) 99 | 100 | 101 | def test_components_can_be_matchers(self): 102 | assert_that(self.counter, is_metric('c', 'foo', '1', none())) 103 | assert_that(self.timer, is_not(is_metric('ms', 'foo', '100', none()))) 104 | -------------------------------------------------------------------------------- /src/perfmetrics/testing/tests/test_observation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function, absolute_import, division 5 | __docformat__ = "restructuredtext en" 6 | 7 | 8 | import functools 9 | 10 | import unittest 11 | 12 | from hamcrest import none 13 | from hamcrest import assert_that 14 | from hamcrest import contains_exactly as contains 15 | from hamcrest import is_ 16 | from hamcrest import has_length 17 | from hamcrest import calling 18 | from hamcrest import raises 19 | 20 | from ..matchers import is_counter 21 | from ..matchers import is_timer 22 | from ..matchers import is_gauge 23 | from ..matchers import is_set 24 | 25 | from ..observation import Observation as Metric 26 | 27 | 28 | class TestMetricParsing(unittest.TestCase): 29 | 30 | def test_invalid_packet(self): 31 | packet = 'junk' 32 | assert_that(calling(functools.partial(Metric.make_all, packet)), 33 | raises(ValueError)) 34 | 35 | packet = 'foo|bar|baz|junk' 36 | assert_that(calling(functools.partial(Metric.make_all, packet)), 37 | raises(ValueError)) 38 | 39 | packet = 'gorets:1|c|0.1' 40 | assert_that(calling(functools.partial(Metric.make_all, packet)), 41 | raises(ValueError)) 42 | 43 | def test_counter(self): 44 | packet = 'gorets:1|c' 45 | metric = Metric.make_all(packet) 46 | 47 | assert_that(metric, has_length(1)) 48 | metric = metric[0] 49 | 50 | assert_that(metric, is_counter('gorets', '1', none())) 51 | 52 | def test_sampled_counter(self): 53 | packet = 'gorets:1|c|@0.1' 54 | metric = Metric.make_all(packet) 55 | 56 | assert_that(metric, has_length(1)) 57 | metric = metric[0] 58 | 59 | assert_that(metric, is_counter('gorets', '1', 0.1)) 60 | 61 | def test_timer(self): 62 | packet = 'glork:320|ms' 63 | metric = Metric.make_all(packet) 64 | 65 | assert_that(metric, has_length(1)) 66 | metric = metric[0] 67 | 68 | assert_that(metric, is_timer('glork', '320')) 69 | 70 | def test_set(self): 71 | packet = 'glork:3|s' 72 | metric = Metric.make_all(packet) 73 | 74 | assert_that(metric, has_length(1)) 75 | metric = metric[0] 76 | 77 | assert_that(metric, is_set('glork', '3')) 78 | 79 | def test_gauge(self): 80 | packet = 'gaugor:+333|g' 81 | metric = Metric.make_all(packet) 82 | 83 | assert_that(metric, has_length(1)) 84 | metric = metric[0] 85 | 86 | assert_that(metric, is_gauge('gaugor', '+333')) 87 | 88 | def test_multi_metric(self): 89 | packet = 'gorets:1|c\nglork:320|ms\ngaugor:333|g\nuniques:765|s' 90 | metrics = Metric.make_all(packet) 91 | assert_that(metrics, contains(is_counter(), 92 | is_timer(), 93 | is_gauge(), 94 | is_set())) 95 | 96 | def test_metric_string(self): 97 | metric = Metric.make('gaugor:+333|g') 98 | assert_that(str(metric), is_('gaugor:+333|g')) 99 | 100 | packet = 'gorets:1|c|@0.1' 101 | metric = Metric.make_all(packet)[0] 102 | metric = Metric.make_all(str(metric))[0] 103 | 104 | assert_that(metric, is_counter('gorets', '1', 0.1)) 105 | 106 | def test_factory(self): 107 | metric = Metric.make('gaugor:+333|g') 108 | assert_that(str(metric), is_('gaugor:+333|g')) 109 | 110 | assert_that(calling(functools.partial(Metric.make, 'gaugor:+333|g\ngaugor:+333|g')), 111 | raises(ValueError)) 112 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | 7 | from hamcrest.core.base_matcher import BaseMatcher 8 | 9 | 10 | # XXX These matchers are temporary for Python 3.10 pending zodbpickle 2.2 11 | # (nti.testing can't be imported on 3.10 before that version) 12 | 13 | try: 14 | from nti.testing.matchers import is_true # pylint:disable=unused-import 15 | except ImportError: # pragma: no cover 16 | class BoolMatcher(BaseMatcher): 17 | def __init__(self, value): 18 | super(BoolMatcher, self).__init__() 19 | self.value = value 20 | 21 | def _matches(self, item): 22 | return bool(item) == self.value 23 | 24 | def describe_to(self, description): 25 | description.append_text('object with bool() value ').append(str(self.value)) 26 | 27 | def __repr__(self): 28 | return 'object with bool() value ' + str(self.value) 29 | 30 | def is_true(): 31 | """ 32 | Matches an object with a true boolean value. 33 | """ 34 | return BoolMatcher(True) 35 | 36 | try: 37 | from nti.testing.matchers import implements # pylint:disable=unused-import 38 | except ImportError: # pragma: no cover 39 | class Implements(BaseMatcher): 40 | 41 | def __init__(self, iface): 42 | super(Implements, self).__init__() 43 | self.iface = iface 44 | 45 | def _matches(self, item): 46 | return self.iface.implementedBy(item) 47 | 48 | def describe_to(self, description): 49 | description.append_text('object implementing') 50 | description.append_description_of(self.iface) 51 | 52 | def implements(iface): 53 | """ 54 | Matches if the object implements (is a factory for) the given 55 | interface. 56 | 57 | .. seealso:: :meth:`zope.interface.interfaces.ISpecification.implementedBy` 58 | """ 59 | return Implements(iface) 60 | 61 | try: 62 | from nti.testing.matchers import validly_provides # pylint:disable=unused-import 63 | except ImportError: # pragma: no cover 64 | from zope.schema import ValidationError 65 | from zope.schema import getValidationErrors 66 | from zope.interface.exceptions import Invalid 67 | from zope.interface.verify import verifyObject 68 | import hamcrest 69 | 70 | class VerifyProvides(BaseMatcher): 71 | 72 | def __init__(self, iface): 73 | super(VerifyProvides, self).__init__() 74 | self.iface = iface 75 | 76 | def _matches(self, item): 77 | try: 78 | verifyObject(self.iface, item) 79 | return True 80 | except Invalid: 81 | return False 82 | 83 | def describe_to(self, description): 84 | description.append_text('object verifiably providing ').append_description_of( 85 | self.iface) 86 | 87 | def describe_mismatch(self, item, mismatch_description): 88 | md = mismatch_description 89 | 90 | try: 91 | verifyObject(self.iface, item) 92 | except Invalid as x: 93 | # Beginning in zope.interface 5, the Invalid exception subclasses 94 | # like BrokenImplementation, DoesNotImplement, etc, all typically 95 | # have a much nicer error message than they used to, better than we 96 | # were producing. This is especially true now that MultipleInvalid 97 | # is a thing. 98 | x = str(x).strip() 99 | 100 | md.append_text("Using class ").append_description_of(type(item)).append_text(' ') 101 | if x.startswith('The object '): 102 | x = x[len("The object "):] 103 | x = 'the object ' + x 104 | x = x.replace('\n ', '\n ') 105 | md.append_text(x) 106 | 107 | 108 | def verifiably_provides(*ifaces): 109 | """ 110 | Matches if the object verifiably provides the correct interface(s), 111 | as defined by :func:`zope.interface.verify.verifyObject`. This means having 112 | the right attributes 113 | and methods with the right signatures. 114 | 115 | .. note:: This does **not** test schema compliance. For that 116 | (stricter) test, see :func:`validly_provides`. 117 | """ 118 | if len(ifaces) == 1: 119 | return VerifyProvides(ifaces[0]) 120 | 121 | return hamcrest.all_of(*[VerifyProvides(x) for x in ifaces]) 122 | 123 | class VerifyValidSchema(BaseMatcher): 124 | def __init__(self, iface): 125 | super(VerifyValidSchema, self).__init__() 126 | self.iface = iface 127 | 128 | def _matches(self, item): 129 | errors = getValidationErrors(self.iface, item) 130 | return not errors 131 | 132 | def describe_to(self, description): 133 | description.append_text('object validly providing ').append(str(self.iface)) 134 | 135 | def describe_mismatch(self, item, mismatch_description): 136 | x = None 137 | md = mismatch_description 138 | md.append_text(str(type(item))) 139 | 140 | errors = getValidationErrors(self.iface, item) 141 | 142 | for attr, exc in errors: 143 | try: 144 | raise exc 145 | except ValidationError: 146 | md.append_text(' has attribute "') 147 | md.append_text(attr) 148 | md.append_text('" with error "') 149 | md.append_text(repr(exc)) 150 | md.append_text('"\n\t ') 151 | except Invalid as x: # pragma: no cover 152 | md.append_text(str(x)) 153 | 154 | def validly_provides(*ifaces): 155 | """ 156 | Matches if the object verifiably and validly provides the given 157 | schema (interface(s)). 158 | 159 | Verification is done with :mod:`zope.interface` and 160 | :func:`verifiably_provides`, while validation is done with 161 | :func:`zope.schema.getValidationErrors`. 162 | """ 163 | if len(ifaces) == 1: 164 | the_schema = ifaces[0] 165 | return hamcrest.all_of(verifiably_provides(the_schema), VerifyValidSchema(the_schema)) 166 | 167 | prov = verifiably_provides(*ifaces) 168 | valid = [VerifyValidSchema(x) for x in ifaces] 169 | 170 | return hamcrest.all_of(prov, *valid) 171 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/bench_metric.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Benchmarks for metrics. 4 | 5 | """ 6 | from __future__ import absolute_import 7 | from __future__ import division 8 | from __future__ import print_function 9 | 10 | from pyperf import Runner 11 | from pyperf import perf_counter 12 | 13 | from perfmetrics import metric 14 | from perfmetrics import metricmethod 15 | from perfmetrics import set_statsd_client 16 | from perfmetrics import Metric 17 | from perfmetrics.statsd import null_client 18 | 19 | metricsampled_1 = Metric(rate=0.1) 20 | metricsampled_9 = Metric(rate=0.999) 21 | 22 | INNER_LOOPS = 1000 23 | 24 | @metric 25 | def func_with_metric(): 26 | pass 27 | 28 | @metricsampled_1 29 | def func_with_metricsampled_1(): 30 | pass 31 | 32 | @metricsampled_9 33 | def func_with_metricsampled_99(): 34 | pass 35 | 36 | def func_without_metric(): 37 | pass 38 | 39 | class AClass(object): 40 | 41 | @metricmethod 42 | def method_with_metric(self): 43 | pass 44 | 45 | def method_without_metric(self): 46 | pass 47 | 48 | 49 | def _bench_call_func(loops, f): 50 | count = range(loops * INNER_LOOPS) 51 | t0 = perf_counter() 52 | for _ in count: 53 | f() 54 | t1 = perf_counter() 55 | return t1 - t0 56 | 57 | ## 58 | # These four measure the overhead when there is no client installed 59 | # First two are baselines. 60 | ## 61 | 62 | def bench_a_call_func_without_metric(loops): 63 | return _bench_call_func(loops, func_without_metric) 64 | 65 | def bench_a_call_method_without_metric(loops): 66 | return _bench_call_func(loops, AClass().method_without_metric) 67 | 68 | 69 | def bench_call_func_with_metric(loops): 70 | return _bench_call_func(loops, func_with_metric) 71 | 72 | def bench_call_method_with_metric(loops): 73 | return _bench_call_func(loops, AClass().method_with_metric) 74 | 75 | 76 | ## 77 | # This measures the overhead of a trivial client 78 | ## 79 | 80 | def _bench_call_func_with_client(loops, 81 | f=func_with_metric, 82 | client=null_client): 83 | set_statsd_client(client) 84 | result = _bench_call_func(loops, f) 85 | set_statsd_client(None) 86 | return result 87 | 88 | def bench_call_func_with_null_client(loops): 89 | return _bench_call_func_with_client(loops) 90 | 91 | ## 92 | # This measures the sampling overhead. 93 | # It also uses a trivial client so we're not 94 | # dependent on the order of tests. 95 | ## 96 | def bench_call_sampled_1_func_with_null_client(loops): 97 | return _bench_call_func_with_client(loops, func_with_metricsampled_1, null_client) 98 | 99 | def bench_call_sampled_99_func_with_null_client(loops): 100 | return _bench_call_func_with_client(loops, func_with_metricsampled_99, null_client) 101 | 102 | 103 | ## 104 | # This measures actually sending the UDP packet 105 | ## 106 | def bench_call_func_with_udp_client(loops): 107 | return _bench_call_func_with_client( 108 | loops, 109 | func_with_metric, 110 | 'statsd://localhost:8125' 111 | ) 112 | 113 | def main(): 114 | runner = Runner() 115 | for name, func in sorted([ 116 | item for item in globals().items() 117 | if item[0].startswith('bench_') 118 | ]): 119 | runner.bench_time_func(name, func, inner_loops=INNER_LOOPS) 120 | 121 | 122 | if __name__ == '__main__': 123 | main() 124 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/benchmarks/spraytest.py: -------------------------------------------------------------------------------- 1 | 2 | from perfmetrics import Metric 3 | from perfmetrics import set_statsd_client 4 | 5 | 6 | @Metric(rate=0.001) 7 | def myfunction(): 8 | """Do something that might be expensive""" 9 | 10 | 11 | class MyClass(object): 12 | @Metric(rate=0.001, method=True) 13 | def mymethod(self): 14 | """Do some other possibly expensive thing""" 15 | 16 | 17 | set_statsd_client('statsd://localhost:8125') 18 | for i in range(1000000): 19 | myfunction() 20 | MyClass().mymethod() 21 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/test_clientstack.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class Test_ClientStack(unittest.TestCase): 5 | @property 6 | def _class(self): 7 | from perfmetrics.clientstack import ClientStack 8 | return ClientStack 9 | 10 | def test_ctor(self): 11 | obj = self._class() 12 | self.assertIsNotNone(obj.stack) 13 | 14 | def test_push(self): 15 | obj = self._class() 16 | client = object() 17 | obj.push(client) 18 | self.assertEqual(obj.stack, [client]) 19 | 20 | def test_pop_with_client(self): 21 | obj = self._class() 22 | client = object() 23 | obj.stack.append(client) 24 | got = obj.pop() 25 | self.assertIs(got, client) 26 | self.assertEqual(obj.stack, []) 27 | 28 | def test_pop_without_client(self): 29 | obj = self._class() 30 | got = obj.pop() 31 | self.assertIsNone(got) 32 | self.assertEqual(obj.stack, []) 33 | 34 | def test_get_without_client(self): 35 | obj = self._class() 36 | self.assertIsNone(obj.get()) 37 | 38 | def test_get_with_client(self): 39 | obj = self._class() 40 | client = object() 41 | obj.stack.append(client) 42 | self.assertIs(obj.get(), client) 43 | 44 | def test_clear(self): 45 | obj = self._class() 46 | client = object() 47 | obj.stack.append(client) 48 | obj.clear() 49 | self.assertEqual(obj.stack, []) 50 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/test_metric.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | 7 | import unittest 8 | 9 | class MockStatsdClient(object): 10 | def __init__(self): 11 | self.changes = [] 12 | self.timings = [] 13 | self.sentbufs = [] 14 | 15 | def incr(self, stat, count=1, rate=1, buf=None, 16 | rate_applied=False): 17 | self.changes.append((stat, count, rate, buf, rate_applied)) 18 | if buf is not None: 19 | buf.append('count_line') 20 | 21 | def timing(self, stat, ms, rate, buf=None, 22 | rate_applied=False): 23 | self.timings.append((stat, ms, rate, buf, rate_applied)) 24 | if buf is not None: 25 | buf.append('timing_line') 26 | 27 | def sendbuf(self, buf): 28 | self.sentbufs.append(buf) 29 | 30 | 31 | class TestMetric(unittest.TestCase): 32 | 33 | def setUp(self): 34 | self.statsd_client_stack.clear() 35 | 36 | tearDown = setUp 37 | 38 | @property 39 | def _class(self): 40 | from perfmetrics import Metric 41 | return Metric 42 | 43 | def _makeOne(self, *args, **kwargs): 44 | return self._class(*args, **kwargs) 45 | 46 | @property 47 | def statsd_client_stack(self): 48 | from perfmetrics import statsd_client_stack 49 | return statsd_client_stack 50 | 51 | def _add_client(self): 52 | client = MockStatsdClient() 53 | self.statsd_client_stack.push(client) 54 | return client 55 | 56 | def test_ctor_with_defaults(self): 57 | obj = self._makeOne() 58 | self.assertIsNone(obj.stat) 59 | self.assertEqual(obj.rate, 1) 60 | self.assertTrue(obj.count) 61 | self.assertTrue(obj.timing) 62 | from perfmetrics import _util 63 | if not _util.PURE_PYTHON: # pragma: no cover 64 | self.assertIn('_metric', str(obj)) 65 | else: 66 | self.assertNotIn('_metric', str(obj)) 67 | 68 | def test_ctor_with_options(self): 69 | obj = self._makeOne('spam.n.eggs', 0.1, count=False, timing=False) 70 | self.assertEqual(obj.stat, 'spam.n.eggs') 71 | self.assertEqual(obj.rate, 0.1) 72 | self.assertFalse(obj.count) 73 | self.assertFalse(obj.timing) 74 | 75 | def test_decorate_function(self): 76 | args = [] 77 | metric = self._makeOne() 78 | 79 | @metric 80 | def spam(x, y=2): 81 | args.append((x, y)) 82 | 83 | self.assertEqual(spam.__module__, __name__) 84 | self.assertEqual(spam.__name__, 'spam') 85 | 86 | # Call with no statsd client configured. 87 | spam(4, 5) 88 | self.assertEqual(args, [(4, 5)]) 89 | del args[:] 90 | 91 | # Call with a statsd client configured. 92 | client = self._add_client() 93 | spam(6, 1) 94 | self.assertEqual(args, [(6, 1)]) 95 | 96 | stat, delta, rate, buf, rate_applied = client.changes[0] 97 | self.assertEqual(stat, __name__ + '.spam') 98 | self.assertEqual(delta, 1) 99 | self.assertEqual(rate, 1) 100 | self.assertEqual(buf, ['count_line', 'timing_line']) 101 | self.assertTrue(rate_applied) 102 | 103 | self.assertEqual(len(client.timings), 1) 104 | stat, ms, rate, _buf, rate_applied = client.timings[0] 105 | self.assertEqual(stat, __name__ + '.spam.t') 106 | self.assertGreaterEqual(ms, 0) 107 | self.assertLess(ms, 10000) 108 | self.assertEqual(rate, 1) 109 | self.assertTrue(rate_applied) 110 | 111 | self.assertEqual(client.sentbufs, [['count_line', 'timing_line']]) 112 | 113 | def test_decorate_method(self): 114 | args = [] 115 | metricmethod = self._makeOne(method=True) 116 | 117 | class Spam(object): 118 | @metricmethod 119 | def f(self, x, y=2): 120 | args.append((self, x, y)) 121 | 122 | self.assertEqual(Spam.f.__module__, __name__) 123 | self.assertEqual(Spam.f.__name__, 'f') 124 | 125 | # Call with no statsd client configured. 126 | spam = Spam() 127 | spam.f(4, 5) 128 | self.assertEqual(args, [(spam, 4, 5)]) 129 | del args[:] 130 | 131 | # Call with a statsd client configured. 132 | client = self._add_client() 133 | spam.f(6, 1) 134 | self.assertEqual(args, [(spam, 6, 1)]) 135 | 136 | self.assertEqual(len(client.changes), 1) 137 | __traceback_info__ = client.changes 138 | stat, delta, rate, buf, rate_applied = client.changes[0] 139 | self.assertEqual(stat, __name__ + '.Spam.f') 140 | self.assertEqual(delta, 1) 141 | self.assertEqual(rate, 1) 142 | self.assertEqual(buf, ['count_line', 'timing_line']) 143 | self.assertTrue(rate_applied) 144 | 145 | self.assertEqual(len(client.timings), 1) 146 | stat, ms, rate, _buf, rate_applied = client.timings[0] 147 | self.assertEqual(stat, __name__ + '.Spam.f.t') 148 | self.assertGreaterEqual(ms, 0) 149 | self.assertLess(ms, 10000) 150 | self.assertEqual(rate, 1) 151 | self.assertTrue(rate_applied) 152 | 153 | self.assertEqual(client.sentbufs, [['count_line', 'timing_line']]) 154 | 155 | def test_decorate_can_change_timing(self): 156 | metric = self._makeOne() 157 | args = [] 158 | @metric 159 | def spam(x, y=2): 160 | args.append((x, y)) 161 | 162 | metric = spam.__wrapped__ if not hasattr(spam, 'metric_timing') else spam 163 | 164 | self.assertTrue(metric.metric_timing) 165 | self.assertTrue(metric.metric_count) 166 | self.assertEqual(1, metric.metric_rate) 167 | metric.metric_rate = 0 168 | metric.metric_timing = False 169 | metric.metric_count = False 170 | # Call with a statsd client configured. 171 | client = self._add_client() 172 | spam(6, 1) 173 | self.assertEqual(args, [(6, 1)]) 174 | 175 | self.assertEqual(len(client.changes), 0) 176 | 177 | def test_decorate_method_can_change_timing(self): 178 | metricmethod = self._makeOne(method=True) 179 | args = [] 180 | class Spam(object): 181 | @metricmethod 182 | def f(self, x, y=2): 183 | args.append((self, x, y)) 184 | 185 | metric = Spam.f 186 | if not hasattr(metric, 'metric_timing'): 187 | metric = Spam.f.__wrapped__ # pylint:disable=no-member 188 | self.assertTrue(metric.metric_timing) 189 | self.assertTrue(metric.metric_count) 190 | self.assertEqual(1, metric.metric_rate) 191 | metric.metric_rate = 0 192 | metric.metric_timing = False 193 | metric.metric_count = False 194 | # Call with a statsd client configured. 195 | client = self._add_client() 196 | spam = Spam() 197 | spam.f(6, 1) 198 | self.assertEqual(args, [(spam, 6, 1)]) 199 | 200 | self.assertEqual(len(client.changes), 0) 201 | 202 | 203 | def test_decorate_without_timing(self): 204 | args = [] 205 | Metric = self._makeOne 206 | 207 | @Metric('spammy', rate=0.01, timing=False, random=lambda: 0.001) 208 | def spam(x, y=2): 209 | args.append((x, y)) 210 | 211 | self.assertEqual(spam.__module__, __name__) 212 | self.assertEqual(spam.__name__, 'spam') 213 | 214 | # Call with no statsd client configured. 215 | spam(4, 5) 216 | self.assertEqual(args, [(4, 5)]) 217 | del args[:] 218 | 219 | # Call with a statsd client configured. 220 | client = self._add_client() 221 | spam(6, 1) 222 | self.assertEqual(args, [(6, 1)]) 223 | 224 | self.assertEqual(len(client.changes), 1) 225 | stat, delta, rate, buf, rate_applied = client.changes[0] 226 | self.assertEqual(stat, 'spammy') 227 | self.assertEqual(delta, 1) 228 | self.assertEqual(rate, 0.01) 229 | self.assertIsNone(buf) 230 | self.assertTrue(rate_applied) 231 | 232 | self.assertEqual(len(client.timings), 0) 233 | self.assertEqual(client.sentbufs, []) 234 | 235 | def test_decorate_without_count(self): 236 | args = [] 237 | Metric = self._makeOne 238 | 239 | @Metric(count=False) 240 | def spam(x, y=2): 241 | args.append((x, y)) 242 | 243 | self.assertEqual(spam.__module__, __name__) 244 | self.assertEqual(spam.__name__, 'spam') 245 | 246 | # Call with no statsd client configured. 247 | spam(4, 5) 248 | self.assertEqual(args, [(4, 5)]) 249 | del args[:] 250 | 251 | # Call with a statsd client configured. 252 | client = self._add_client() 253 | spam(6, 1) 254 | self.assertEqual(args, [(6, 1)]) 255 | self.assertEqual(client.changes, []) 256 | self.assertEqual(len(client.timings), 1) 257 | 258 | stat, ms, rate, buf, rate_applied = client.timings[0] 259 | self.assertEqual(stat, __name__ + '.spam.t') 260 | self.assertGreaterEqual(ms, 0) 261 | self.assertLess(ms, 10000) 262 | self.assertEqual(rate, 1) 263 | self.assertIsNone(buf) 264 | self.assertTrue(rate_applied) 265 | 266 | self.assertEqual(client.sentbufs, []) 267 | 268 | def test_decorate_with_neither_timing_nor_count(self): 269 | args = [] 270 | Metric = self._makeOne 271 | 272 | @Metric(count=False, timing=False) 273 | def spam(x, y=2): 274 | args.append((x, y)) 275 | 276 | # Call with no statsd client configured. 277 | spam(4, 5) 278 | self.assertEqual(args, [(4, 5)]) 279 | del args[:] 280 | 281 | # Call with a statsd client configured. 282 | client = self._add_client() 283 | spam(6, 1) 284 | self.assertEqual(args, [(6, 1)]) 285 | self.assertEqual(client.changes, []) 286 | self.assertEqual(len(client.timings), 0) 287 | 288 | self.assertEqual(client.sentbufs, []) 289 | 290 | def test_ignore_function_sample(self): 291 | args = [] 292 | Metric = self._makeOne 293 | 294 | @Metric(rate=0.99, random=lambda: 0.999) 295 | def spam(x, y=2): 296 | args.append((x, y)) 297 | return 77 298 | 299 | client = self._add_client() 300 | self.assertEqual(77, spam(6, 1)) 301 | 302 | # The function was called 303 | self.assertEqual(args, [(6, 1)]) 304 | 305 | # No packets were sent because the random value was too high. 306 | self.assertFalse(client.changes) 307 | self.assertFalse(client.timings) 308 | self.assertFalse(client.sentbufs) 309 | 310 | def test_as_context_manager_with_stat_name(self): 311 | args = [] 312 | Metric = self._makeOne 313 | 314 | def spam(x, y=2): 315 | with Metric('thing-done'): 316 | args.append((x, y)) 317 | 318 | # Call with no statsd client configured. 319 | spam(4, 5) 320 | self.assertEqual(args, [(4, 5)]) 321 | del args[:] 322 | 323 | # Call with a statsd client configured. 324 | client = self._add_client() 325 | spam(6, 1) 326 | self.assertEqual(args, [(6, 1)]) 327 | 328 | stat, delta, rate, buf, rate_applied = client.changes[0] 329 | self.assertEqual(stat, 'thing-done') 330 | self.assertEqual(delta, 1) 331 | self.assertEqual(rate, 1) 332 | self.assertEqual(buf, ['count_line', 'timing_line']) 333 | self.assertTrue(rate_applied) 334 | 335 | self.assertEqual(len(client.timings), 1) 336 | stat, ms, rate, _buf, rate_applied = client.timings[0] 337 | self.assertEqual(stat, 'thing-done.t') 338 | self.assertGreaterEqual(ms, 0) 339 | self.assertLess(ms, 10000) 340 | self.assertEqual(rate, 1) 341 | self.assertTrue(rate_applied) 342 | 343 | self.assertEqual(client.sentbufs, [['count_line', 'timing_line']]) 344 | 345 | def test_ignore_context_manager_sample(self): 346 | args = [] 347 | Metric = self._makeOne 348 | 349 | def spam(x, y=2): 350 | with Metric('thing-done', rate=0.99, random=lambda: 0.999): 351 | args.append((x, y)) 352 | return 88 353 | 354 | client = self._add_client() 355 | self.assertEqual(88, spam(6, 716)) 356 | 357 | # The function was called 358 | self.assertEqual(args, [(6, 716)]) 359 | 360 | # No packets were sent because the random value was too high. 361 | self.assertFalse(client.changes) 362 | self.assertFalse(client.timings) 363 | self.assertFalse(client.sentbufs) 364 | 365 | def test_as_context_manager_without_stat_name(self): 366 | args = [] 367 | Metric = self._makeOne 368 | 369 | def spam(x, y=2): 370 | with Metric(): 371 | args.append((x, y)) 372 | 373 | client = self._add_client() 374 | spam(6, 1) 375 | self.assertEqual(args, [(6, 1)]) 376 | 377 | self.assertFalse(client.changes) 378 | self.assertFalse(client.timings) 379 | self.assertFalse(client.sentbufs) 380 | 381 | def test_as_context_manager_without_timing(self): 382 | args = [] 383 | Metric = self._makeOne 384 | 385 | def spam(x, y=2): 386 | with Metric('thing-done', timing=False): 387 | args.append((x, y)) 388 | 389 | client = self._add_client() 390 | spam(6, 1) 391 | self.assertEqual(args, [(6, 1)]) 392 | 393 | self.assertEqual(len(client.changes), 1) 394 | 395 | stat, delta, rate, buf, rate_applied = client.changes[0] 396 | self.assertEqual(stat, 'thing-done') 397 | self.assertEqual(delta, 1) 398 | self.assertEqual(rate, 1) 399 | self.assertEqual(buf, ['count_line']) 400 | self.assertTrue(rate_applied) 401 | 402 | self.assertEqual(len(client.timings), 0) 403 | 404 | self.assertEqual(client.sentbufs, [['count_line']]) 405 | 406 | def test_as_context_manager_without_count(self): 407 | args = [] 408 | Metric = self._makeOne 409 | 410 | def spam(x, y=2): 411 | with Metric('thing-done', count=False): 412 | args.append((x, y)) 413 | 414 | # Call with a statsd client configured. 415 | client = self._add_client() 416 | spam(6, 1) 417 | self.assertEqual(args, [(6, 1)]) 418 | 419 | self.assertEqual(len(client.changes), 0) 420 | self.assertEqual(len(client.timings), 1) 421 | stat, ms, rate, _buf, rate_applied = client.timings[0] 422 | self.assertEqual(stat, 'thing-done.t') 423 | self.assertGreaterEqual(ms, 0) 424 | self.assertLess(ms, 10000) 425 | self.assertEqual(rate, 1) 426 | self.assertTrue(rate_applied) 427 | 428 | self.assertEqual(client.sentbufs, [['timing_line']]) 429 | 430 | def test_as_context_manager_with_neither_count_nor_timing(self): 431 | args = [] 432 | Metric = self._makeOne 433 | 434 | def spam(x, y=2): 435 | with Metric('thing-done', count=False, timing=False): 436 | args.append((x, y)) 437 | 438 | # Call with a statsd client configured. 439 | client = self._add_client() 440 | spam(6, 1) 441 | self.assertEqual(args, [(6, 1)]) 442 | 443 | self.assertEqual(len(client.changes), 0) 444 | self.assertEqual(len(client.timings), 0) 445 | self.assertEqual(client.sentbufs, []) 446 | 447 | class SequenceDecorator(object): 448 | 449 | def __init__(self, *args, **kwargs): 450 | from perfmetrics import Metric 451 | from perfmetrics import MetricMod 452 | 453 | self.args = args 454 | self.kwargs = kwargs 455 | self.metric = Metric(*args, **kwargs) 456 | self.metricmod = MetricMod("%s") 457 | 458 | def __getattr__(self, name): 459 | return getattr(self.metric, name) 460 | 461 | def __call__(self, func): 462 | return self.metricmod(self.metric(func)) 463 | 464 | def __enter__(self): 465 | self.metricmod.__enter__() 466 | self.metric.__enter__() 467 | 468 | def __exit__(self, t, v, tb): 469 | self.metric.__exit__(t, v, tb) 470 | self.metricmod.__exit__(t, v, tb) 471 | 472 | def __str__(self): 473 | return str(self.metricmod) 474 | 475 | 476 | class TestMetricMod(TestMetric): 477 | 478 | def _makeOne(self, *args, **kwargs): 479 | return SequenceDecorator(*args, **kwargs) 480 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/test_perfmetrics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | 7 | import unittest 8 | 9 | 10 | class Test_statsd_config_functions(unittest.TestCase): 11 | 12 | def setUp(self): 13 | from perfmetrics import set_statsd_client 14 | set_statsd_client(None) 15 | 16 | tearDown = setUp 17 | 18 | def test_unconfigured(self): 19 | from perfmetrics import statsd_client 20 | self.assertIsNone(statsd_client()) 21 | 22 | def test_configured_with_uri(self): 23 | from perfmetrics import set_statsd_client 24 | set_statsd_client('statsd://localhost:8125') 25 | from perfmetrics import StatsdClient 26 | from perfmetrics import statsd_client 27 | client = statsd_client() 28 | self.addCleanup(client.close) 29 | self.assertIsInstance(client, StatsdClient) 30 | 31 | def test_configured_with_other_client(self): 32 | other_client = object() 33 | from perfmetrics import set_statsd_client 34 | set_statsd_client(other_client) 35 | from perfmetrics import statsd_client 36 | self.assertIs(statsd_client(), other_client) 37 | 38 | 39 | class Test_statsd_client_from_uri(unittest.TestCase): 40 | 41 | def _call(self, uri): 42 | from perfmetrics import statsd_client_from_uri 43 | client = statsd_client_from_uri(uri) 44 | self.addCleanup(client.close) 45 | return client 46 | 47 | def test_local_uri(self): 48 | client = self._call('statsd://localhost:8129') 49 | self.assertIsNotNone(client.udp_sock) 50 | 51 | def test_unsupported_uri(self): 52 | with self.assertRaises(ValueError): 53 | self._call('http://localhost:8125') 54 | 55 | def test_with_custom_prefix(self): 56 | client = self._call('statsd://localhost:8129?prefix=spamalot') 57 | self.assertEqual(client.prefix, 'spamalot.') 58 | 59 | 60 | class TestDisabledDecorators(unittest.TestCase): 61 | 62 | def setUp(self): 63 | import sys 64 | import os 65 | __import__('perfmetrics') 66 | self.old_mod = sys.modules['perfmetrics'] 67 | 68 | del sys.modules['perfmetrics'] 69 | os.environ['PERFMETRICS_DISABLE_DECORATOR'] = '1' 70 | self.perfmetrics = __import__("perfmetrics") 71 | del os.environ['PERFMETRICS_DISABLE_DECORATOR'] 72 | 73 | def tearDown(self): 74 | import sys 75 | sys.modules['perfmetrics'] = self.old_mod 76 | 77 | def test_metric(self): 78 | 79 | def func(): 80 | 'does nothing' 81 | 82 | func2 = self.perfmetrics.metric(func) 83 | self.assertIs(func, func2) 84 | 85 | def test_metricmethod(self): 86 | 87 | def func(): 88 | 'does nothing' 89 | 90 | func2 = self.perfmetrics.metricmethod(func) 91 | self.assertIs(func, func2) 92 | 93 | def test_Metric(self): 94 | 95 | def func(): 96 | 'does nothing' 97 | 98 | func2 = self.perfmetrics.Metric()(func) 99 | self.assertIs(func, func2) 100 | 101 | def test_MetricMod(self): 102 | 103 | def func(): 104 | 'does nothing' 105 | 106 | func2 = self.perfmetrics.MetricMod('%s')(func) 107 | self.assertIs(func, func2) 108 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/test_pyramid.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | 7 | import unittest 8 | 9 | 10 | class MockRegistry(object): 11 | def __init__(self, statsd_uri=None): 12 | self.settings = {} 13 | if statsd_uri: 14 | self.settings['statsd_uri'] = statsd_uri 15 | 16 | class MockConfig(object): 17 | def __init__(self, statsd_uri=None): 18 | self.registry = MockRegistry(statsd_uri) 19 | self.tweens = [] 20 | 21 | def add_tween(self, name): 22 | self.tweens.append(name) 23 | 24 | 25 | class Test_includeme(unittest.TestCase): 26 | 27 | def _call(self, config): 28 | from perfmetrics import includeme 29 | includeme(config) 30 | 31 | def _make_config(self, statsd_uri): 32 | return MockConfig(statsd_uri) 33 | 34 | def test_without_statsd_uri(self): 35 | config = self._make_config(None) 36 | self._call(config) 37 | self.assertFalse(config.tweens) 38 | 39 | def test_with_statsd_uri(self): 40 | config = self._make_config('statsd://localhost:9999') 41 | self._call(config) 42 | self.assertEqual(config.tweens, ['perfmetrics.tween']) 43 | 44 | 45 | class Test_tween(unittest.TestCase): 46 | 47 | def setUp(self): 48 | from perfmetrics import set_statsd_client, statsd_client_stack 49 | set_statsd_client(None) 50 | statsd_client_stack.clear() 51 | 52 | def _call(self, handler, registry): 53 | from perfmetrics import tween 54 | return tween(handler, registry) 55 | 56 | def _make_registry(self, statsd_uri): 57 | return MockRegistry(statsd_uri) 58 | 59 | def test_call_tween(self): 60 | clients = [] 61 | 62 | def dummy_handler(_request): 63 | from perfmetrics import statsd_client 64 | client = statsd_client() 65 | self.addCleanup(client.close) 66 | clients.append(client) 67 | return 'ok!' 68 | 69 | registry = self._make_registry('statsd://localhost:9999') 70 | tween = self._call(dummy_handler, registry) 71 | response = tween(object()) 72 | self.assertEqual(response, 'ok!') 73 | self.assertEqual(len(clients), 1) 74 | from perfmetrics.statsd import StatsdClient 75 | self.assertIsInstance(clients[0], StatsdClient) 76 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/test_statsd.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | from __future__ import division 4 | from __future__ import print_function 5 | 6 | import unittest 7 | 8 | from zope.interface import verify 9 | 10 | from hamcrest import assert_that 11 | 12 | from perfmetrics.interfaces import IStatsdClient 13 | 14 | from . import validly_provides 15 | from . import is_true 16 | from . import implements 17 | 18 | 19 | # pylint:disable=protected-access 20 | # pylint:disable=too-many-public-methods 21 | 22 | class MockSocket(object): 23 | def __init__(self, error=None): 24 | self.sent = [] 25 | self.error = error 26 | 27 | def sendto(self, data, addr): 28 | if self.error is not None: 29 | raise self.error # pylint:disable=raising-bad-type 30 | self.sent.append((data, addr)) 31 | 32 | def close(self): 33 | pass 34 | 35 | 36 | class TestBasics(unittest.TestCase): 37 | 38 | @property 39 | def _class(self): 40 | from perfmetrics.statsd import StatsdClient 41 | return StatsdClient 42 | 43 | def _makeOne(self, *args, **kwargs): 44 | kind = kwargs.pop('kind', None) or self._class 45 | inst = kind(*args, **kwargs) 46 | self.addCleanup(inst.close) 47 | return inst 48 | 49 | def test_provides(self): 50 | assert_that(self._makeOne(), validly_provides(IStatsdClient)) 51 | 52 | def test_implements(self): 53 | kind = type(self._makeOne()) 54 | assert_that(kind, implements(IStatsdClient)) 55 | # That just checks the declaration of the interface, it doesn't 56 | # do a static check of attributes. 57 | assert_that( 58 | verify.verifyClass(IStatsdClient, kind), 59 | is_true()) 60 | 61 | def test_true(self): 62 | assert_that(self._makeOne(), is_true()) 63 | 64 | class TestNullStatsdClient(TestBasics): 65 | 66 | @property 67 | def _class(self): 68 | from perfmetrics.statsd import NullStatsdClient 69 | return NullStatsdClient 70 | 71 | class TestStatsdClient(TestBasics): 72 | 73 | sent = () 74 | 75 | # The main stat name, as entered by the application 76 | STAT_NAME = 'some.thing' 77 | # The main stat name, as sent on the wire 78 | STAT_NAMEB = b'some.thing' 79 | STAT_NAME2 = 'other.thing' 80 | STAT_NAME2B = b'other.thing' 81 | 82 | def _make(self, patch_socket=True, error=None, prefix=''): 83 | obj = self._makeOne(prefix=prefix) 84 | 85 | if patch_socket: 86 | obj.udp_sock.close() 87 | obj.udp_sock = MockSocket(error) 88 | self.sent = obj.udp_sock.sent 89 | 90 | self.addCleanup(obj.close) 91 | return obj 92 | 93 | def test_ctor_with_defaults(self): 94 | obj = self._make(patch_socket=False) 95 | self.assertIsNotNone(obj.udp_sock) 96 | self.assertIsNotNone(obj.addr) 97 | self.assertEqual(obj.prefix, '') 98 | 99 | def test_ctor_with_options(self): 100 | obj = self._make(patch_socket=False, prefix='foo') 101 | self.assertIsNotNone(obj.udp_sock) 102 | self.assertIsNotNone(obj.addr) 103 | self.assertEqual(obj.prefix, 'foo.') 104 | 105 | def test_timing_with_rate_1(self): 106 | obj = self._make() 107 | obj.timing(self.STAT_NAME, 750) 108 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':750|ms', obj.addr)]) 109 | 110 | def test_timing_with_rate_too_low(self): 111 | obj = self._make() 112 | obj.timing(self.STAT_NAME, 750, rate=-1) 113 | self.assertEqual(self.sent, []) 114 | 115 | def test_timing_with_buf(self): 116 | obj = self._make() 117 | buf = [] 118 | obj.timing(self.STAT_NAME, 750, buf=buf) 119 | self.assertEqual(self.sent, []) 120 | obj.sendbuf(buf) 121 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':750|ms', obj.addr)]) 122 | 123 | def test_gauge_with_rate_1(self): 124 | obj = self._make() 125 | obj.gauge(self.STAT_NAME, 50) 126 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':50|g', obj.addr)]) 127 | 128 | def test_gauge_with_rate_too_low(self): 129 | obj = self._make() 130 | obj.gauge(self.STAT_NAME, 50, rate=-1) 131 | self.assertEqual(self.sent, []) 132 | 133 | def test_gauge_with_buf(self): 134 | obj = self._make() 135 | buf = [] 136 | obj.gauge(self.STAT_NAME, 50, buf=buf) 137 | self.assertEqual(self.sent, []) 138 | obj.sendbuf(buf) 139 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':50|g', obj.addr)]) 140 | 141 | def test_incr_with_one_metric(self): 142 | obj = self._make() 143 | obj.incr(self.STAT_NAME) 144 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':1|c', obj.addr)]) 145 | 146 | def test_incr_with_two_metrics(self): 147 | obj = self._make() 148 | buf = [] 149 | obj.incr(self.STAT_NAME, buf=buf) 150 | obj.incr(self.STAT_NAME2, buf=buf) 151 | obj.sendbuf(buf) 152 | self.assertEqual(self.sent, 153 | [(self.STAT_NAMEB + b':1|c\n' + self.STAT_NAME2B + b':1|c', obj.addr)]) 154 | 155 | def test_incr_with_rate_too_low(self): 156 | obj = self._make() 157 | obj.incr(self.STAT_NAME, rate=-1) 158 | self.assertEqual(self.sent, []) 159 | 160 | def test_decr(self): 161 | obj = self._make() 162 | obj.decr(self.STAT_NAME) 163 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':-1|c', obj.addr)]) 164 | 165 | def test_incr_by_amount_with_one_metric(self): 166 | obj = self._make() 167 | obj.incr(self.STAT_NAME, 51) 168 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':51|c', obj.addr)]) 169 | 170 | def test_incr_by_amount_with_two_metrics(self): 171 | obj = self._make() 172 | buf = [] 173 | obj.incr(self.STAT_NAME, 42, buf=buf) 174 | obj.incr(self.STAT_NAME2, -41, buf=buf) 175 | obj.sendbuf(buf) 176 | self.assertEqual(self.sent, 177 | [(self.STAT_NAMEB + b':42|c\n' + self.STAT_NAME2B + b':-41|c', obj.addr)]) 178 | 179 | def test_incr_with_rate_hit(self): 180 | obj = self._make() 181 | obj.random = lambda: 0.01 182 | obj.incr(self.STAT_NAME, 51, rate=0.1) 183 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':51|c|@0.1', obj.addr)]) 184 | 185 | def test_incr_with_rate_miss(self): 186 | obj = self._make() 187 | obj.random = lambda: 0.99 188 | obj.incr(self.STAT_NAME, 51, rate=0.1) 189 | self.assertEqual(self.sent, []) 190 | 191 | def test_send_with_ioerror(self): 192 | obj = self._make(error=IOError('synthetic')) 193 | obj._send('some.thing:41|g') 194 | self.assertEqual(self.sent, []) 195 | 196 | def test_sendbuf_with_ioerror(self): 197 | obj = self._make(error=IOError('synthetic')) 198 | obj.sendbuf(['some.thing:41|g']) 199 | self.assertEqual(self.sent, []) 200 | 201 | def test_sendbuf_with_empty_buf(self): 202 | obj = self._make() 203 | obj.sendbuf([]) 204 | self.assertEqual(self.sent, []) 205 | 206 | def test_set_add(self): 207 | obj = self._make() 208 | obj.set_add(self.STAT_NAME, 42) 209 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':42|s', obj.addr)]) 210 | 211 | def test_set_add_with_buf(self): 212 | obj = self._make() 213 | buf = [] 214 | obj.set_add(self.STAT_NAME, 42, buf=buf) 215 | obj.set_add(self.STAT_NAME2, 23, buf=buf) 216 | obj.sendbuf(buf) 217 | self.assertEqual(self.sent, 218 | [(self.STAT_NAMEB + b':42|s\n' + self.STAT_NAME2B + b':23|s', obj.addr)]) 219 | 220 | 221 | def test_set_add_with_rate_hit(self): 222 | obj = self._make() 223 | obj.random = lambda: 0.01 224 | obj.set_add(self.STAT_NAME, 51, rate=0.1) 225 | self.assertEqual(self.sent, [(self.STAT_NAMEB + b':51|s', obj.addr)]) 226 | 227 | def test_set_add_with_rate_miss(self): 228 | obj = self._make() 229 | obj.random = lambda: 0.99 230 | obj.set_add(self.STAT_NAME, 51, rate=0.1) 231 | self.assertEqual(self.sent, []) 232 | 233 | class TestStatsdClientMod(TestStatsdClient): 234 | 235 | STAT_NAMEB = b'wrap.some.thing' 236 | STAT_NAME2B = b'wrap.other.thing' 237 | 238 | @property 239 | def _class(self): 240 | from perfmetrics.statsd import StatsdClientMod 241 | return StatsdClientMod 242 | 243 | def _makeOne(self, *args, **kwargs): 244 | from perfmetrics.statsd import StatsdClient 245 | kwargs['kind'] = super(TestStatsdClientMod, self)._class 246 | wrapped = super(TestStatsdClientMod, self)._makeOne(*args, **kwargs) 247 | assert type(wrapped) is StatsdClient # pylint:disable=unidiomatic-typecheck 248 | # Be sure to use a format string that alters the stat name so we 249 | # can prove the method is getting called. With __getattr__ there, we could 250 | # silently call through to the wrapped class without knowing it. 251 | return self._class(wrapped, 'wrap.%s') 252 | -------------------------------------------------------------------------------- /src/perfmetrics/tests/test_wsgi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from __future__ import division 5 | from __future__ import print_function 6 | 7 | import unittest 8 | 9 | class Test_make_statsd_app(unittest.TestCase): 10 | 11 | def setUp(self): 12 | from perfmetrics import set_statsd_client, statsd_client_stack 13 | set_statsd_client(None) 14 | statsd_client_stack.clear() 15 | 16 | def _call(self, nextapp, statsd_uri): 17 | from perfmetrics import make_statsd_app 18 | app = make_statsd_app(nextapp, None, statsd_uri) 19 | if hasattr(app, 'statsd_client'): 20 | self.addCleanup(app.statsd_client.close) 21 | return app 22 | 23 | def test_without_statsd_uri(self): 24 | clients = [] 25 | 26 | def dummy_app(_environ, _start_response): 27 | from perfmetrics import statsd_client 28 | clients.append(statsd_client()) 29 | return ['ok.'] 30 | 31 | app = self._call(dummy_app, '') 32 | response = app({}, None) 33 | self.assertEqual(response, ['ok.']) 34 | self.assertEqual(len(clients), 1) 35 | self.assertIsNone(clients[0]) 36 | 37 | def test_with_statsd_uri(self): 38 | clients = [] 39 | 40 | def dummy_app(_environ, _start_response): 41 | from perfmetrics import statsd_client 42 | clients.append(statsd_client()) 43 | return ['ok.'] 44 | 45 | app = self._call(dummy_app, 'statsd://localhost:9999') 46 | response = app({}, None) 47 | self.assertEqual(response, ['ok.']) 48 | self.assertEqual(len(clients), 1) 49 | from perfmetrics.statsd import StatsdClient 50 | self.assertIsInstance(clients[0], StatsdClient) 51 | -------------------------------------------------------------------------------- /src/perfmetrics/wsgi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Optional WSGI integration. 4 | 5 | """ 6 | from __future__ import absolute_import 7 | from __future__ import division 8 | from __future__ import print_function 9 | 10 | from .statsd import statsd_client_from_uri 11 | from .clientstack import client_stack as statsd_client_stack 12 | from .metric import Metric 13 | 14 | def make_statsd_app(nextapp, _globals=None, statsd_uri=''): 15 | """ 16 | Create a WSGI filter app that sets up Statsd for each request. 17 | 18 | If no *statsd_uri* is given, returns *nextapp* unchanged. 19 | 20 | .. versionchanged:: 3.0 21 | 22 | The returned app callable makes the statsd client that it 23 | uses available at the ``statsd_client`` attribute. 24 | """ 25 | if not statsd_uri: 26 | # Disabled. 27 | return nextapp 28 | 29 | client = statsd_client_from_uri(statsd_uri) 30 | 31 | nextapp = Metric('perfmetrics.wsgi')(nextapp) 32 | def app(environ, start_response): 33 | statsd_client_stack.push(client) 34 | try: 35 | return nextapp(environ, start_response) 36 | finally: 37 | statsd_client_stack.pop() 38 | 39 | app.statsd_client = client 40 | 41 | return app 42 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,py310,py311,py312,py313,pypy3,py37,coverage 3 | 4 | [testenv] 5 | extras = 6 | test 7 | commands = 8 | zope-testrunner --test-path=src --auto-color --auto-progress [] # substitute with tox positional args 9 | 10 | [testenv:coverage] 11 | usedevelop = true 12 | setenv = 13 | PURE_PYTHON = 1 14 | basepython = 15 | python3 16 | commands = 17 | coverage run -m zope.testrunner --test-path=src [] 18 | coverage report --fail-under=100 19 | deps = 20 | coverage 21 | 22 | 23 | [testenv:docs] 24 | commands = 25 | sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html 26 | extras = 27 | docs 28 | --------------------------------------------------------------------------------