├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ └── publish.yml ├── .gitignore ├── AUTHORS ├── ChangeLog ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst ├── installation.rst ├── instruments.rst ├── make.bat ├── reporters.rst └── wsgi.rst ├── metrology ├── __init__.py ├── exceptions.py ├── instruments │ ├── __init__.py │ ├── counter.py │ ├── derive.py │ ├── gauge.py │ ├── healthcheck.py │ ├── histogram.py │ ├── meter.py │ └── timer.py ├── registry.py ├── reporter │ ├── __init__.py │ ├── base.py │ ├── ganglia.py │ ├── graphite.py │ ├── librato.py │ ├── logger.py │ └── statsd.py ├── stats │ ├── __init__.py │ ├── ewma.py │ ├── sample.py │ └── snapshot.py ├── utils │ ├── __init__.py │ └── periodic.py └── wsgi.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── tests ├── instruments │ ├── test_counter.py │ ├── test_derive.py │ ├── test_gauge.py │ ├── test_healthcheck.py │ ├── test_histogram.py │ ├── test_meter.py │ └── test_timer.py ├── reporter │ ├── test_ganglia.py │ ├── test_graphite.py │ ├── test_graphite_pickle.py │ ├── test_librato.py │ ├── test_logger.py │ └── test_statsd.py ├── stats │ ├── test_ewma.py │ ├── test_sample.py │ └── test_snapshot.py ├── test_metrology.py ├── test_registry.py └── test_wsgi.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python: [2.7, 3.7, 3.8] 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Setup Python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: ${{ matrix.python }} 15 | - name: Install Tox and any other packages 16 | run: pip install tox 17 | - name: Run Tox 18 | run: tox -e py 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master, ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 14 * * 4' 11 | jobs: 12 | analyse: 13 | name: Analyse 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v2 19 | with: 20 | # We must fetch at least the immediate parents so that if this is 21 | # a pull request then we can checkout the head. 22 | fetch-depth: 2 23 | 24 | # If this run was triggered by a pull request event, then checkout 25 | # the head of the pull request instead of the merge commit. 26 | - run: git checkout HEAD^2 27 | if: ${{ github.event_name == 'pull_request' }} 28 | 29 | # Initializes the CodeQL tools for scanning. 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v1 32 | # Override language selection by uncommenting this and choosing your languages 33 | # with: 34 | # languages: go, javascript, csharp, python, cpp, java 35 | 36 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 37 | # If this step fails, then you should remove it and run the build manually (see below) 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@v1 40 | 41 | # ℹ️ Command-line programs to run using the OS shell. 42 | # 📚 https://git.io/JvXDl 43 | 44 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 45 | # and modify them (or add more) to build your code if your project 46 | # uses a compiled language 47 | 48 | #- run: | 49 | # make bootstrap 50 | # make release 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@v1 54 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Set up Python 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: '3.x' 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install setuptools wheel twine 18 | - name: Build and publish 19 | env: 20 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 21 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 22 | run: | 23 | python setup.py sdist bdist_wheel 24 | twine upload dist/* 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .AppleDouble 2 | *.pyc 3 | :2e_* 4 | *.tmproj 5 | .*.swp 6 | build 7 | dist 8 | MANIFEST 9 | docs/_build/ 10 | *.egg-info 11 | .eggs/ 12 | .coverage 13 | coverage/ 14 | .tox/ 15 | .DS_Store 16 | .idea 17 | .venv 18 | htmlcov/ 19 | .cache 20 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alexey Diyan 2 | Alexis Svinartchouk 3 | Artur Dryomov 4 | Damien Dubé 5 | Jacob Kaplan-Moss 6 | John Crocker 7 | Jonathan Dorival 8 | Kamil Warguła 9 | Misiek 10 | Roman Prykhodchenko 11 | Timothée Peignier 12 | Timothée Peignier 13 | steven-lai 14 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 1.3.0 5 | ----- 6 | 7 | * Add tags support. 8 | * Various improvements. 9 | 10 | 1.2.4 11 | ----- 12 | 13 | * Bump version 14 | 15 | 1.2.3 16 | ----- 17 | 18 | * bump version 19 | * For timer/meter decorators, return the return value (#35) 20 | * Gardening (#32) 21 | 22 | 1.2.2 23 | ----- 24 | 25 | * bump version 26 | * Test against python 3.6 27 | * Send more detailed metrics from statsd reporter (#31) 28 | * fix doc 29 | 30 | 1.2.1 31 | ----- 32 | 33 | * bump version 34 | * Add total time to Timer (#30) 35 | * Added 99th and 999th percentile to Graphite reporter. (#27) 36 | 37 | 1.2.0 38 | ----- 39 | 40 | * bump version 41 | * Implement StatsD reporter (#26) 42 | * Update authors file 43 | 44 | 1.1.0 45 | ----- 46 | 47 | * bump version 48 | * Allow to use timer and meter as decorators and with context manager (#24) 49 | * Py3 compatibility for plaintext Graphite reporter (#23) 50 | 51 | 1.0.1 52 | ----- 53 | 54 | * bump version 55 | * Fix histogram clearance. Close #21 (#22) 56 | 57 | 1.0.0 58 | ----- 59 | 60 | * bump version 61 | * Publish from travis-ci 62 | * Add basic WSGI documentation 63 | * Add basic wsgi middleware 64 | * Ensure we run against trusty 65 | * Update tests 66 | 67 | 0.10.1 68 | ------ 69 | 70 | * bump version 71 | * ensure new tick is an integer 72 | * use gcc >= 4.7 on travis 73 | * remove profiler mention 74 | * unbreaking upload 75 | 76 | 0.10.0 77 | ------ 78 | 79 | * bump version 80 | * use new Atomic classes 81 | * bump version 82 | 83 | 0.9.1 84 | ----- 85 | 86 | * bump version 87 | * update travis-ci configuration 88 | * graphite reporter. fix socket.sendall mistyping 89 | * graphite reporter. fix socket.sendall mistyping 90 | * add wheel support 91 | * add missing @properry. close #16 92 | 93 | 0.9.0 94 | ----- 95 | 96 | * bump version 97 | * add a ganglia reporter 98 | 99 | 0.8.0 100 | ----- 101 | 102 | * bump version 103 | * drop 2.6 support 104 | * update AUTHORS 105 | * refactor ExponentiallyDecayingSample 106 | * bump version 107 | * fix meter ticking. close #12 108 | * Added graphite pickle protocol as well as buffering 109 | 110 | 0.7.2 111 | ----- 112 | 113 | * bump version 114 | * fix stdev calculation 115 | * update authors 116 | * fix tests formatting 117 | * Use Python syntax highlighting in the readme file 118 | * silence pip install 119 | * add docs build in tox 120 | 121 | 0.7.1 122 | ----- 123 | 124 | * bump version 125 | * going threadless for meter, following metrics path 126 | * pep8 cleanups 127 | * make travis ci run python 3.3 128 | 129 | 0.7.0 130 | ----- 131 | 132 | * bump version 133 | * add derive instrument 134 | * make PeriodicTask a deamon thread 135 | 136 | 0.6.3 137 | ----- 138 | 139 | * bump version 140 | * make it run with python 3.3 141 | * don't require statprof as a dependency 142 | * re-add statprof as dependency 143 | 144 | 0.6.2 145 | ----- 146 | 147 | * bump version 148 | * Sets the content-type to JSON when writing to Librato 149 | 150 | 0.6.1 151 | ----- 152 | 153 | * bump version 154 | * fix sample OverflowError 155 | * cleanup imports 156 | * remove statprof from python3 test env 157 | 158 | 0.6 159 | --- 160 | 161 | * bump version 162 | * warn about profiler not working on python 3 163 | * skip profiler test for python3 164 | * adding a Profiler instrument 165 | * fix typo in logger reporter 166 | * fix typo in graphite reporter 167 | * fix stddev return value 168 | * remove statsd reporter import 169 | * remove non-working statsd reporter for now 170 | * add astrolabe in requirements 171 | 172 | 0.5 173 | --- 174 | 175 | * bump version 176 | * require newest astrolabe 177 | * use astrolabe for timers 178 | * fix typo 179 | * bump version 180 | * Fixing Graphite & statsd socket error 181 | * fix python 2.6 tests 182 | * add requirements.txt file 183 | * run tests for jython too 184 | 185 | 0.4 186 | --- 187 | 188 | * bump version 189 | * update README with the new librato reporter 190 | * add docs and tests for librato reporter 191 | * make librato reporter actually report data 192 | * add missing project files 193 | * improve package description 194 | 195 | 0.3.3 196 | ----- 197 | 198 | * bump version 199 | * add missing MANIFEST 200 | * make README more complete 201 | * add some reporter documentation 202 | * raise a RegistryException rahter than a RuntimeError 203 | * use new format 204 | * prettier code, fixed one typo, import just time.time 205 | 206 | 0.3.2 207 | ----- 208 | 209 | * bump version 210 | * make counter more atomic 211 | 212 | 0.3.1 213 | ----- 214 | 215 | * update requirements and bump version 216 | 217 | 0.3 218 | --- 219 | 220 | * bump version 221 | * make it work with atomic 0.3 222 | * remove empty docstring 223 | 224 | 0.2 225 | --- 226 | 227 | * pep8 fix 228 | * add health check 229 | * prepare for next version 230 | * use docstring to provide better documentation 231 | * add access to histogram 232 | * add base docs 233 | * add base sphinx files 234 | * add acknowledgement 235 | * small steps towards more reporter 236 | * dumb work on librato reporter 237 | * a less naïve periodic task implementation 238 | * fix tests formatting 239 | * improve README 240 | * add graphite reporter 241 | * fix tests for python 2.6 and 3.2 242 | * improve README 243 | * add logger reporter 244 | * add missing methods 245 | * improve README 246 | * week-end project is still a thing 247 | * add base files 248 | * ignore files 249 | * fix typo 250 | * READ ME 251 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (©) 2012 Timothée Peignier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include docs/* 2 | include AUTHORS 3 | include LICENSE 4 | include README.rst 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Metrology 3 | ========= 4 | 5 | A library to easily measure what's going on in your python. 6 | 7 | Metrology allows you to add instruments to your python code and hook them to external reporting tools like Graphite so as to better understand what's going on in your running python program. 8 | 9 | Installing 10 | ========== 11 | 12 | To install : :: 13 | 14 | pip install metrology 15 | 16 | API 17 | === 18 | 19 | Gauge 20 | ----- 21 | 22 | A gauge is an instantaneous measurement of a value 23 | 24 | .. code-block:: python 25 | 26 | class JobGauge(metrology.instruments.Gauge): 27 | def value(self): 28 | return len(queue) 29 | gauge = Metrology.gauge('pending-jobs', JobGauge()) 30 | 31 | 32 | Counters 33 | -------- 34 | 35 | A counter is like a gauge, but you can increment or decrement its value 36 | 37 | .. code-block:: python 38 | 39 | counter = Metrology.counter('pending-jobs') 40 | counter.increment() 41 | counter.decrement() 42 | counter.count 43 | 44 | Meters 45 | ------ 46 | 47 | A meter measures the rate of events over time (e.g., "requests per second"). 48 | In addition to the mean rate, you can also track 1, 5 and 15 minutes moving averages 49 | 50 | .. code-block:: python 51 | 52 | meter = Metrology.meter('requests') 53 | meter.mark() 54 | meter.count 55 | 56 | or as a decorator: 57 | 58 | .. code-block:: python 59 | 60 | @Metrology.meter('requests') 61 | def do_this_again(): 62 | # do something 63 | 64 | or with context manager: 65 | 66 | .. code-block:: python 67 | 68 | with Metrology.meter('requests'): 69 | # do something 70 | 71 | Timers 72 | ------ 73 | 74 | A timer measures both the rate that a particular piece of code is called and the distribution of its duration 75 | 76 | .. code-block:: python 77 | 78 | timer = Metrology.timer('responses') 79 | with timer: 80 | do_something() 81 | 82 | or as a decorator: 83 | 84 | .. code-block:: python 85 | 86 | @Metrology.timer('responses') 87 | def response(): 88 | # do_something 89 | 90 | 91 | Utilization Timer 92 | ----------------- 93 | 94 | A specialized timer that calculates the percentage of wall-clock time that was spent 95 | 96 | .. code-block:: python 97 | 98 | utimer = Metrology.utilization_timer('responses') 99 | with utimer: 100 | do_something() 101 | 102 | Tagging metrics 103 | --------------- 104 | 105 | All metrics can be tagged if the reporter supports it (currently: Graphite, Librato, Logger. The StatsD reporter supports the Datadog tag format because no official tag standard has been devised by the project). 106 | Tags can be arbitrary key-value pairs. Just assign a dict as metric name. A 'name'-entry is required. 107 | 108 | .. code-block:: python 109 | 110 | counter = Metrology.counter({ 111 | 'name': 'pending-jobs', 112 | 'host': 'backend', 113 | 'priority': 'high' 114 | }) 115 | counter.increment() 116 | counter.decrement() 117 | counter.count 118 | 119 | All metric types support tagging. 120 | 121 | Reporters 122 | ========= 123 | 124 | Logger Reporter 125 | --------------- 126 | 127 | A logging reporter that write metrics to a logger 128 | 129 | .. code-block:: python 130 | 131 | reporter = LoggerReporter(level=logging.INFO, interval=10) 132 | reporter.start() 133 | 134 | 135 | Graphite Reporter 136 | ----------------- 137 | 138 | A graphite reporter that send metrics to graphite 139 | 140 | .. code-block:: python 141 | 142 | reporter = GraphiteReporter('graphite.local', 2003) 143 | reporter.start() 144 | 145 | 146 | Librato Reporter 147 | ---------------- 148 | 149 | A librator metric reporter that send metrics to librato API 150 | 151 | .. code-block:: python 152 | 153 | reporter = LibratoReporter("", "") 154 | reporter.start() 155 | 156 | 157 | Ganglia Reporter 158 | ---------------- 159 | 160 | A ganglia reporter that sends metrics to gmond. 161 | 162 | .. code-block:: python 163 | 164 | reporter = GangliaReporter("Group Name", "localhost", 8649, "udp", interval=60) 165 | reporter.start() 166 | 167 | StatsD Reporter 168 | ---------------- 169 | 170 | A statsd reporter that sends metrics to statsd daemon. 171 | 172 | .. code-block:: python 173 | 174 | reporter = StatsDReporter('localhost', 3333, conn_type='tcp') 175 | reporter.start() 176 | 177 | or use default UDP setting: 178 | 179 | .. code-block:: python 180 | 181 | reporter = StatsDReporter('localhost', 3333) 182 | reporter.start() 183 | 184 | 185 | Acknowledgement 186 | =============== 187 | 188 | This is heavily inspired by the awesome `metrics `_ library. 189 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/metrology.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/metrology.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/metrology" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/metrology" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # metrology documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Mar 22 11:40:53 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing 7 | # 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 | import os 16 | import sys 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 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 | # needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 30 | extensions = ['sphinx.ext.autodoc'] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The suffix of source filenames. 36 | source_suffix = '.rst' 37 | 38 | # The encoding of source files. 39 | # source_encoding = 'utf-8-sig' 40 | 41 | # The master toctree document. 42 | master_doc = 'index' 43 | 44 | # General information about the project. 45 | project = u'metrology' 46 | copyright = u'2012, Timothée Peignier' 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | # The short X.Y version. 53 | version = '1.3' 54 | # The full version, including alpha/beta/rc tags. 55 | release = '1.3.0' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | # today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | # today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all 72 | # documents. 73 | # default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | # add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | # add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | # show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | # modindex_common_prefix = [] 91 | 92 | 93 | # -- Options for HTML output -------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'default' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | # html_theme_options = {} 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | # html_theme_path = [] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | # html_title = None 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | # html_short_title = None 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | # html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | # html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | # html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | # html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | # html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | # html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | # html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | # html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | # html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | # html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | # html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | # html_show_copyright = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | # html_use_opensearch = '' 165 | 166 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 167 | # html_file_suffix = None 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'metrologydoc' 171 | 172 | 173 | # -- Options for LaTeX output ------------------------------------------------- 174 | 175 | # The paper size ('letter' or 'a4'). 176 | # latex_paper_size = 'letter' 177 | 178 | # The font size ('10pt', '11pt' or '12pt'). 179 | # latex_font_size = '10pt' 180 | 181 | # Grouping the document tree into LaTeX files. List of tuples 182 | # (source start file, target name, title, author, documentclass [howto/manual]) 183 | latex_documents = [ 184 | ('index', 'metrology.tex', u'metrology Documentation', 185 | u'Timothee Peignier', 'manual'), 186 | ] 187 | 188 | # The name of an image file (relative to this directory) to place at the top 189 | # of the title page. 190 | # latex_logo = None 191 | 192 | # For "manual" documents, if this is true, then toplevel headings are parts, 193 | # not chapters. 194 | # latex_use_parts = False 195 | 196 | # If true, show page references after internal links. 197 | # latex_show_pagerefs = False 198 | 199 | # If true, show URL addresses after external links. 200 | # latex_show_urls = False 201 | 202 | # Additional stuff for the LaTeX preamble. 203 | # latex_preamble = '' 204 | 205 | # Documents to append as an appendix to all manuals. 206 | # latex_appendices = [] 207 | 208 | # If false, no module index is generated. 209 | # latex_domain_indices = True 210 | 211 | 212 | # -- Options for manual page output ------------------------------------------ 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [ 217 | ('index', 'metrology', u'metrology Documentation', 218 | [u'Timothee Peignier'], 1) 219 | ] 220 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | A library to easily measure what's going on in your python. 5 | 6 | Metrology allows you to add instruments to your python code and hook them to external reporting tools like Graphite so as to better understand what's going on in your running python program. 7 | 8 | You can report bugs and discuss features on the `issues page `_. 9 | 10 | Table Of Contents 11 | ================= 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | installation 17 | instruments 18 | reporters 19 | wsgi 20 | 21 | Indices and tables 22 | ================== 23 | 24 | * :ref:`genindex` 25 | * :ref:`modindex` 26 | * :ref:`search` 27 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _ref-installation: 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | Either check out Metrology from `GitHub `_ or to pull a release off `PyPI `_ :: 8 | 9 | pip install metrology 10 | -------------------------------------------------------------------------------- /docs/instruments.rst: -------------------------------------------------------------------------------- 1 | .. _ref-instruments: 2 | 3 | =========== 4 | Instruments 5 | =========== 6 | 7 | Gauges 8 | ====== 9 | 10 | .. automodule:: metrology.instruments.gauge 11 | :members: 12 | 13 | 14 | Counters 15 | ======== 16 | 17 | .. automodule:: metrology.instruments.counter 18 | :members: 19 | 20 | Derive 21 | ====== 22 | 23 | .. automodule:: metrology.instruments.derive 24 | :members: 25 | 26 | 27 | Meters 28 | ====== 29 | 30 | .. automodule:: metrology.instruments.meter 31 | :members: 32 | 33 | Histograms 34 | ========== 35 | 36 | .. automodule:: metrology.instruments.histogram 37 | :members: 38 | 39 | 40 | Timers and utilization timers 41 | ============================= 42 | 43 | .. automodule:: metrology.instruments.timer 44 | :members: 45 | :inherited-members: 46 | 47 | 48 | Health Checks 49 | ============= 50 | 51 | .. automodule:: metrology.instruments.healthcheck 52 | :members: 53 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | if errorlevel 1 exit /b 1 46 | echo. 47 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 48 | goto end 49 | ) 50 | 51 | if "%1" == "dirhtml" ( 52 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 53 | if errorlevel 1 exit /b 1 54 | echo. 55 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 56 | goto end 57 | ) 58 | 59 | if "%1" == "singlehtml" ( 60 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 61 | if errorlevel 1 exit /b 1 62 | echo. 63 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 64 | goto end 65 | ) 66 | 67 | if "%1" == "pickle" ( 68 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 69 | if errorlevel 1 exit /b 1 70 | echo. 71 | echo.Build finished; now you can process the pickle files. 72 | goto end 73 | ) 74 | 75 | if "%1" == "json" ( 76 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished; now you can process the JSON files. 80 | goto end 81 | ) 82 | 83 | if "%1" == "htmlhelp" ( 84 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished; now you can run HTML Help Workshop with the ^ 88 | .hhp project file in %BUILDDIR%/htmlhelp. 89 | goto end 90 | ) 91 | 92 | if "%1" == "qthelp" ( 93 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 94 | if errorlevel 1 exit /b 1 95 | echo. 96 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 97 | .qhcp project file in %BUILDDIR%/qthelp, like this: 98 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\metrology.qhcp 99 | echo.To view the help file: 100 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\metrology.ghc 101 | goto end 102 | ) 103 | 104 | if "%1" == "devhelp" ( 105 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 106 | if errorlevel 1 exit /b 1 107 | echo. 108 | echo.Build finished. 109 | goto end 110 | ) 111 | 112 | if "%1" == "epub" ( 113 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 117 | goto end 118 | ) 119 | 120 | if "%1" == "latex" ( 121 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 122 | if errorlevel 1 exit /b 1 123 | echo. 124 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 125 | goto end 126 | ) 127 | 128 | if "%1" == "text" ( 129 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 130 | if errorlevel 1 exit /b 1 131 | echo. 132 | echo.Build finished. The text files are in %BUILDDIR%/text. 133 | goto end 134 | ) 135 | 136 | if "%1" == "man" ( 137 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 141 | goto end 142 | ) 143 | 144 | if "%1" == "changes" ( 145 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.The overview file is in %BUILDDIR%/changes. 149 | goto end 150 | ) 151 | 152 | if "%1" == "linkcheck" ( 153 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Link check complete; look for any errors in the above output ^ 157 | or in %BUILDDIR%/linkcheck/output.txt. 158 | goto end 159 | ) 160 | 161 | if "%1" == "doctest" ( 162 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 163 | if errorlevel 1 exit /b 1 164 | echo. 165 | echo.Testing of doctests in the sources finished, look at the ^ 166 | results in %BUILDDIR%/doctest/output.txt. 167 | goto end 168 | ) 169 | 170 | :end 171 | -------------------------------------------------------------------------------- /docs/reporters.rst: -------------------------------------------------------------------------------- 1 | .. _ref-reporters: 2 | 3 | ========= 4 | Reporters 5 | ========= 6 | 7 | Graphite 8 | ======== 9 | 10 | .. automodule:: metrology.reporter.graphite 11 | :members: 12 | 13 | Logging 14 | ======= 15 | 16 | .. automodule:: metrology.reporter.logger 17 | :members: 18 | 19 | Librato 20 | ======= 21 | 22 | .. automodule:: metrology.reporter.librato 23 | :members: 24 | 25 | Ganglia 26 | ======= 27 | 28 | .. automodule:: metrology.reporter.ganglia 29 | :members: 30 | -------------------------------------------------------------------------------- /docs/wsgi.rst: -------------------------------------------------------------------------------- 1 | .. _ref-wsgi: 2 | 3 | =============== 4 | WSGI Middleware 5 | =============== 6 | 7 | WSGI 8 | ==== 9 | 10 | .. automodule:: metrology.wsgi 11 | :members: 12 | -------------------------------------------------------------------------------- /metrology/__init__.py: -------------------------------------------------------------------------------- 1 | from metrology.registry import registry 2 | 3 | 4 | class Metrology(object): 5 | @classmethod 6 | def get(cls, name): 7 | return registry.get(name) 8 | 9 | @classmethod 10 | def counter(cls, name): 11 | return registry.counter(name) 12 | 13 | @classmethod 14 | def derive(cls, name): 15 | return registry.derive(name) 16 | 17 | @classmethod 18 | def meter(cls, name): 19 | return registry.meter(name) 20 | 21 | @classmethod 22 | def gauge(cls, name, gauge): 23 | return registry.gauge(name, gauge) 24 | 25 | @classmethod 26 | def timer(cls, name): 27 | return registry.timer(name) 28 | 29 | @classmethod 30 | def utilization_timer(cls, name): 31 | return registry.utilization_timer(name) 32 | 33 | @classmethod 34 | def histogram(cls, name, histogram=None): 35 | return registry.histogram(name, histogram) 36 | 37 | @classmethod 38 | def health_check(cls, name, health_check): 39 | return registry.health_check(name, health_check) 40 | 41 | @classmethod 42 | def stop(cls): 43 | return registry.stop() 44 | -------------------------------------------------------------------------------- /metrology/exceptions.py: -------------------------------------------------------------------------------- 1 | class MetrologyException(Exception): 2 | pass 3 | 4 | 5 | class RegistryException(MetrologyException): 6 | pass 7 | 8 | 9 | class ArgumentException(MetrologyException): 10 | pass 11 | 12 | 13 | class ReporterException(MetrologyException): 14 | pass 15 | -------------------------------------------------------------------------------- /metrology/instruments/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- flake8: noqa -*- 2 | from metrology.instruments.counter import Counter 3 | from metrology.instruments.derive import Derive 4 | from metrology.instruments.gauge import Gauge 5 | from metrology.instruments.histogram import ( 6 | Histogram, 7 | HistogramExponentiallyDecaying, 8 | HistogramUniform 9 | ) 10 | from metrology.instruments.meter import Meter 11 | from metrology.instruments.timer import Timer, UtilizationTimer 12 | 13 | 14 | __all__ = ( 15 | Counter, 16 | Derive, 17 | Gauge, 18 | Histogram, 19 | HistogramExponentiallyDecaying, 20 | HistogramUniform, 21 | Meter, 22 | Timer, 23 | UtilizationTimer 24 | ) 25 | -------------------------------------------------------------------------------- /metrology/instruments/counter.py: -------------------------------------------------------------------------------- 1 | from atomic import AtomicLong 2 | 3 | 4 | class Counter(object): 5 | """ 6 | A counter is like a gauge, but you can increment or decrement its value :: 7 | 8 | counter = Metrology.counter('pending-jobs') 9 | counter.increment() 10 | counter.decrement() 11 | counter.count 12 | 13 | """ 14 | def __init__(self): 15 | self._count = AtomicLong(0) 16 | 17 | def increment(self, value=1): 18 | """Increment the counter. By default it will increment by 1. 19 | 20 | :param value: value to increment the counter. 21 | """ 22 | self._count += value 23 | 24 | def decrement(self, value=1): 25 | """Decrement the counter. By default it will decrement by 1. 26 | 27 | :param value: value to decrement the counter. 28 | """ 29 | self._count -= value 30 | 31 | def clear(self): 32 | self._count.value = 0 33 | 34 | @property 35 | def count(self): 36 | """Return the current value of the counter.""" 37 | return self._count.value 38 | -------------------------------------------------------------------------------- /metrology/instruments/derive.py: -------------------------------------------------------------------------------- 1 | from atomic import AtomicLong 2 | 3 | from metrology.instruments.meter import Meter 4 | from metrology.stats import EWMA 5 | 6 | 7 | class Derive(Meter): 8 | """ 9 | A derive is like a meter but accepts an absolute counter as input. 10 | 11 | derive = Metrology.derive('network.io') 12 | derive.mark() 13 | derive.count 14 | 15 | """ 16 | def __init__(self, average_class=EWMA): 17 | self.last = AtomicLong(0) 18 | super(Derive, self).__init__(average_class) 19 | 20 | def mark(self, value=1): 21 | """Record an event with the derive. 22 | 23 | :param value: counter value to record 24 | """ 25 | last = self.last.get_and_set(value) 26 | if last <= value: 27 | value = value - last 28 | super(Derive, self).mark(value) 29 | -------------------------------------------------------------------------------- /metrology/instruments/gauge.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import math 4 | 5 | from atomic import AtomicLong 6 | 7 | 8 | class Gauge(object): 9 | """ 10 | A gauge is an instantaneous measurement of a value :: 11 | 12 | class JobGauge(metrology.instruments.Gauge): 13 | @property 14 | def value(self): 15 | return len(queue) 16 | 17 | gauge = Metrology.gauge('pending-jobs', JobGauge()) 18 | 19 | """ 20 | @property 21 | def value(self): 22 | """""" 23 | raise NotImplementedError 24 | 25 | 26 | class RatioGauge(Gauge): 27 | """ 28 | A ratio gauge is a simple way to create a gauge which is 29 | the ratio between two numbers. 30 | """ 31 | def numerator(self): 32 | raise NotImplementedError 33 | 34 | def denominator(self): 35 | raise NotImplementedError 36 | 37 | @property 38 | def value(self): 39 | d = self.denominator() 40 | if math.isnan(d) or math.isinf(d) or d == 0.0 or d == 0: 41 | return float('nan') 42 | return self.numerator() / d 43 | 44 | 45 | class PercentGauge(RatioGauge): 46 | """ 47 | A percent gauge is a ratio gauge where the result is normalized 48 | to a value between 0 and 100. 49 | """ 50 | @property 51 | def value(self): 52 | value = super(PercentGauge, self).value 53 | return value * 100 54 | 55 | 56 | class ToggleGauge(Gauge): 57 | _value = AtomicLong(1) 58 | 59 | @property 60 | def value(self): 61 | try: 62 | return self._value.value 63 | finally: 64 | self._value.value = 0 65 | -------------------------------------------------------------------------------- /metrology/instruments/healthcheck.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class HealthCheck(object): 4 | """ 5 | A health check is a small self-test to verify that a specific component or 6 | responsibility is performing correctly :: 7 | 8 | class DatabaseHealthCheck(metrology.healthcheck.HealthCheck): 9 | def __init__(self, database): 10 | self.database = database 11 | 12 | def check(self): 13 | if database.ping(): 14 | return True 15 | return False 16 | 17 | health_check = Metrology.health_check('database', 18 | DatabaseHealthCheck(database)) 19 | health_check.check() 20 | 21 | """ 22 | def check(self): 23 | """Returns True if what is being checked is healthy""" 24 | raise NotImplementedError 25 | -------------------------------------------------------------------------------- /metrology/instruments/histogram.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import sys 4 | 5 | from atomic import AtomicLong, AtomicLongArray 6 | 7 | from metrology.stats.sample import UniformSample, ExponentiallyDecayingSample 8 | 9 | 10 | class Histogram(object): 11 | """ 12 | A histogram measures the statistical distribution of values in a stream of 13 | data. In addition to minimum, maximum, mean, it also measures median, 14 | 75th, 90th, 95th, 98th, 99th, and 99.9th percentiles :: 15 | 16 | histogram = Metrology.histogram('response-sizes') 17 | histogram.update(len(response.content)) 18 | 19 | Metrology provides two types of histograms: uniform and exponentially 20 | decaying. 21 | """ 22 | DEFAULT_SAMPLE_SIZE = 1028 23 | DEFAULT_ALPHA = 0.015 24 | 25 | def __init__(self, sample): 26 | self.sample = sample 27 | self.counter = AtomicLong(0) 28 | self.minimum = AtomicLong(sys.maxsize) 29 | self.maximum = AtomicLong(-sys.maxsize - 1) 30 | self.sum = AtomicLong(0) 31 | self.var = AtomicLongArray([-1, 0]) 32 | 33 | def clear(self): 34 | self.sample.clear() 35 | self.counter.value = 0 36 | self.minimum.value = sys.maxsize 37 | self.maximum.value = (-sys.maxsize - 1) 38 | self.sum.value = 0 39 | self.var.value = [-1, 0] 40 | 41 | def update(self, value): 42 | self.counter += 1 43 | self.sample.update(value) 44 | self.max = value 45 | self.min = value 46 | self.sum += value 47 | self.update_variance(value) 48 | 49 | @property 50 | def snapshot(self): 51 | return self.sample.snapshot() 52 | 53 | @property 54 | def count(self): 55 | """Return number of values.""" 56 | return self.counter.value 57 | 58 | def get_max(self): 59 | if self.counter.value > 0: 60 | return self.maximum.value 61 | return 0.0 62 | 63 | def set_max(self, potential_max): 64 | done = False 65 | while not done: 66 | current_max = self.maximum.value 67 | done = (current_max is not None and current_max >= potential_max) \ 68 | or self.maximum.compare_and_swap(current_max, potential_max) 69 | 70 | max = property(get_max, set_max, doc="""Returns the maximun value.""") 71 | 72 | def get_min(self): 73 | if self.counter.value > 0: 74 | return self.minimum.value 75 | return 0.0 76 | 77 | def set_min(self, potential_min): 78 | done = False 79 | while not done: 80 | current_min = self.minimum.value 81 | done = (current_min is not None and current_min <= potential_min) \ 82 | or self.minimum.compare_and_swap(current_min, potential_min) 83 | 84 | min = property(get_min, set_min, doc="""Returns the minimum value.""") 85 | 86 | @property 87 | def total(self): 88 | """Returns the total value.""" 89 | return self.sum.value 90 | 91 | @property 92 | def mean(self): 93 | """Returns the mean value.""" 94 | if self.counter.value > 0: 95 | return self.sum.value / self.counter.value 96 | return 0.0 97 | 98 | @property 99 | def stddev(self): 100 | """Returns the standard deviation.""" 101 | if self.counter.value > 0: 102 | return self.variance ** .5 103 | return 0.0 104 | 105 | @property 106 | def variance(self): 107 | """Returns variance""" 108 | if self.counter.value <= 1: 109 | return 0.0 110 | return self.var.value[1] / (self.counter.value - 1) 111 | 112 | def update_variance(self, value): 113 | def variance(old_values): 114 | if old_values[0] == -1: 115 | new_values = (value, 0) 116 | else: 117 | old_m = old_values[0] 118 | old_s = old_values[1] 119 | 120 | new_m = old_m + ((value - old_m) / self.counter.value) 121 | new_s = old_s + ((value - old_m) * (value - new_m)) 122 | 123 | new_values = (new_m, new_s) 124 | return new_values 125 | self.var.value = variance(self.var.value) 126 | 127 | 128 | class HistogramUniform(Histogram): 129 | """ 130 | A uniform histogram produces quantiles which are valid for the entirely of 131 | the histogram's lifetime. It will return a median value, for example, which 132 | is the median of all the values the histogram has ever been updated with. 133 | 134 | Use a uniform histogram when you're interested in long-term measurements. 135 | Don't use one where you'd want to know if the distribution of the 136 | underlying data stream has changed recently. 137 | """ 138 | def __init__(self): 139 | sample = UniformSample(self.DEFAULT_SAMPLE_SIZE) 140 | super(HistogramUniform, self).__init__(sample) 141 | 142 | 143 | class HistogramExponentiallyDecaying(Histogram): 144 | """ 145 | A exponentially decaying histogram produces quantiles which are 146 | representative of approximately the last five minutes of data. 147 | 148 | Unlike the uniform histogram, a biased histogram represents recent data, 149 | allowing you to know very quickly if the distribution of the data has 150 | changed. 151 | 152 | """ 153 | def __init__(self): 154 | sample = ExponentiallyDecayingSample(self.DEFAULT_SAMPLE_SIZE, 155 | self.DEFAULT_ALPHA) 156 | super(HistogramExponentiallyDecaying, self).__init__(sample) 157 | -------------------------------------------------------------------------------- /metrology/instruments/meter.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from time import time 3 | 4 | from atomic import AtomicLong 5 | 6 | from metrology.stats import EWMA 7 | from metrology.utils import now 8 | 9 | 10 | def ticker(method): 11 | @wraps(method) 12 | def wrapper(self, *args, **kwargs): 13 | self._tick() 14 | return method(self, *args, **kwargs) 15 | return wrapper 16 | 17 | 18 | class Meter(object): 19 | """A meter measures the rate of events over time 20 | (e.g., "requests per second"). 21 | 22 | In addition to the mean rate, you can also track 1, 5 and 15 minutes moving 23 | averages :: 24 | 25 | meter = Metrology.meter('requests') 26 | meter.mark() 27 | meter.count 28 | 29 | """ 30 | def __init__(self, average_class=EWMA): 31 | self.counter = AtomicLong(0) 32 | self.start_time = now() 33 | self.last_tick = AtomicLong(self.start_time) 34 | 35 | self.interval = EWMA.INTERVAL 36 | self.m1_rate = EWMA.m1() 37 | self.m5_rate = EWMA.m5() 38 | self.m15_rate = EWMA.m15() 39 | 40 | def _tick(self): 41 | old_tick, new_tick = self.last_tick.value, time() 42 | age = new_tick - old_tick 43 | ticks = int(age / self.interval) 44 | new_tick = old_tick + int(ticks * self.interval) 45 | if ticks and self.last_tick.compare_and_swap(old_tick, new_tick): 46 | for _ in range(ticks): 47 | self.tick() 48 | 49 | def __call__(self, *args, **kwargs): 50 | if args and hasattr(args[0], '__call__'): 51 | _orig_func = args[0] 52 | 53 | def _decorator(*args, **kwargs): 54 | with self: 55 | return _orig_func(*args, **kwargs) 56 | return _decorator 57 | 58 | def __enter__(self): 59 | pass 60 | 61 | def __exit__(self, exc, exv, trace): 62 | self.mark() 63 | 64 | @property 65 | def count(self): 66 | """Returns the total number of events that have been recorded.""" 67 | return self.counter.value 68 | 69 | def clear(self): 70 | self.counter.value = 0 71 | self.start_time = time() 72 | 73 | self.m1_rate.clear() 74 | self.m5_rate.clear() 75 | self.m15_rate.clear() 76 | 77 | @ticker 78 | def mark(self, value=1): 79 | """Record an event with the meter. By default it will record one event. 80 | 81 | :param value: number of event to record 82 | """ 83 | self.counter += value 84 | self.m1_rate.update(value) 85 | self.m5_rate.update(value) 86 | self.m15_rate.update(value) 87 | 88 | def tick(self): 89 | self.m1_rate.tick() 90 | self.m5_rate.tick() 91 | self.m15_rate.tick() 92 | 93 | @property 94 | @ticker 95 | def one_minute_rate(self): 96 | """Returns the one-minute average rate.""" 97 | return self.m1_rate.rate 98 | 99 | @property 100 | @ticker 101 | def five_minute_rate(self): 102 | """Returns the five-minute average rate.""" 103 | return self.m5_rate.rate 104 | 105 | @property 106 | @ticker 107 | def fifteen_minute_rate(self): 108 | """Returns the fifteen-minute average rate.""" 109 | return self.m15_rate.rate 110 | 111 | @property 112 | def mean_rate(self): 113 | """ 114 | Returns the mean rate of the events since the start of the process. 115 | """ 116 | if self.counter.value == 0: 117 | return 0.0 118 | else: 119 | elapsed = time() - self.start_time 120 | return self.counter.value / elapsed 121 | 122 | def stop(self): 123 | pass 124 | -------------------------------------------------------------------------------- /metrology/instruments/timer.py: -------------------------------------------------------------------------------- 1 | from astrolabe.interval import Interval 2 | 3 | from metrology.instruments.histogram import HistogramExponentiallyDecaying 4 | from metrology.instruments.meter import Meter 5 | 6 | 7 | class Timer(object): 8 | """ 9 | A timer measures both the rate that a particular piece of code is called 10 | and the distribution of its duration :: 11 | 12 | timer = Metrology.timer('responses') 13 | with timer: 14 | do_something() 15 | 16 | """ 17 | def __init__(self, histogram=HistogramExponentiallyDecaying): 18 | self.meter = Meter() 19 | self.histogram = histogram() 20 | 21 | def __call__(self, *args, **kwargs): 22 | if args and hasattr(args[0], '__call__'): 23 | _orig_func = args[0] 24 | 25 | def _decorator(*args, **kwargs): 26 | with self: 27 | return _orig_func(*args, **kwargs) 28 | return _decorator 29 | 30 | def clear(self): 31 | self.meter.clear() 32 | self.histogram.clear() 33 | 34 | def update(self, duration): 35 | """Records the duration of an operation.""" 36 | if duration >= 0: 37 | self.meter.mark() 38 | self.histogram.update(duration) 39 | 40 | @property 41 | def snapshot(self): 42 | return self.histogram.snapshot 43 | 44 | def __enter__(self): 45 | self.interval = Interval.now() 46 | return self 47 | 48 | def __exit__(self, type, value, callback): 49 | duration = self.interval.stop() 50 | self.update(duration) 51 | 52 | @property 53 | def total_time(self): 54 | """Returns the total time spent.""" 55 | return self.histogram.total 56 | 57 | @property 58 | def count(self): 59 | """Returns the number of measurements that have been made.""" 60 | return self.histogram.count 61 | 62 | @property 63 | def one_minute_rate(self): 64 | """Returns the one-minute average rate.""" 65 | return self.meter.one_minute_rate 66 | 67 | @property 68 | def five_minute_rate(self): 69 | """Returns the five-minute average rate.""" 70 | return self.meter.five_minute_rate 71 | 72 | @property 73 | def fifteen_minute_rate(self): 74 | """Returns the fifteen-minute average rate.""" 75 | return self.meter.fifteen_minute_rate 76 | 77 | @property 78 | def mean_rate(self): 79 | """ 80 | Returns the mean rate of the events since the start of the process. 81 | """ 82 | return self.meter.mean_rate 83 | 84 | @property 85 | def min(self): 86 | """Returns the minimum amount of time spent in the operation.""" 87 | return self.histogram.min 88 | 89 | @property 90 | def max(self): 91 | """Returns the maximum amount of time spent in the operation.""" 92 | return self.histogram.max 93 | 94 | @property 95 | def mean(self): 96 | """Returns the mean time spent in the operation.""" 97 | return self.histogram.mean 98 | 99 | @property 100 | def stddev(self): 101 | """ 102 | Returns the standard deviation of the mean spent in the operation. 103 | """ 104 | return self.histogram.stddev 105 | 106 | def stop(self): 107 | self.meter.stop() 108 | 109 | 110 | class UtilizationTimer(Timer): 111 | """ 112 | A specialized timer that calculates the percentage of wall-clock time that 113 | was spent :: 114 | 115 | utimer = Metrology.utilization_timer('responses') 116 | with utimer: 117 | do_something() 118 | 119 | """ 120 | def __init__(self, histogram=HistogramExponentiallyDecaying): 121 | super(UtilizationTimer, self).__init__(histogram) 122 | self.duration_meter = Meter() 123 | 124 | def clear(self): 125 | super(UtilizationTimer, self).clear() 126 | self.duration_meter.clear() 127 | 128 | def update(self, duration): 129 | super(UtilizationTimer, self).update(duration) 130 | if duration >= 0: 131 | self.duration_meter.mark(duration) 132 | 133 | @property 134 | def one_minute_utilization(self): 135 | """Returns the one-minute average utilization as a percentage.""" 136 | return self.duration_meter.one_minute_rate 137 | 138 | @property 139 | def five_minute_utilization(self): 140 | """Returns the five-minute average utilization as a percentage.""" 141 | return self.duration_meter.five_minute_rate 142 | 143 | @property 144 | def fifteen_minute_utilization(self): 145 | """Returns the fifteen-minute average utilization as a percentage.""" 146 | return self.duration_meter.fifteen_minute_rate 147 | 148 | @property 149 | def mean_utilization(self): 150 | """ 151 | Returns the mean (average) utilization as a percentage since the 152 | process started. 153 | """ 154 | return self.duration_meter.mean_rate 155 | 156 | def stop(self): 157 | super(UtilizationTimer, self).stop() 158 | self.duration_meter.stop() 159 | -------------------------------------------------------------------------------- /metrology/registry.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from threading import RLock 4 | 5 | from metrology.exceptions import RegistryException, ArgumentException 6 | from metrology.instruments import ( 7 | Counter, 8 | Derive, 9 | HistogramUniform, 10 | Meter, 11 | Timer, 12 | UtilizationTimer 13 | ) 14 | 15 | 16 | class Registry(object): 17 | def __init__(self): 18 | self.lock = RLock() 19 | self.metrics = {} 20 | 21 | def clear(self): 22 | with self.lock: 23 | for metric in self.metrics.values(): 24 | if hasattr(metric, 'stop'): 25 | metric.stop() 26 | self.metrics.clear() 27 | 28 | def counter(self, name): 29 | return self.add_or_get(name, Counter) 30 | 31 | def meter(self, name): 32 | return self.add_or_get(name, Meter) 33 | 34 | def gauge(self, name, klass): 35 | return self.add_or_get(name, klass) 36 | 37 | def timer(self, name): 38 | return self.add_or_get(name, Timer) 39 | 40 | def utilization_timer(self, name): 41 | return self.add_or_get(name, UtilizationTimer) 42 | 43 | def health_check(self, name, klass): 44 | return self.add_or_get(name, klass) 45 | 46 | def histogram(self, name, klass=None): 47 | if not klass: 48 | klass = HistogramUniform 49 | return self.add_or_get(name, klass) 50 | 51 | def derive(self, name): 52 | return self.add_or_get(name, Derive) 53 | 54 | def get(self, name): 55 | with self.lock: 56 | key = self._compose_key(name) 57 | return self.metrics[key] 58 | 59 | def add(self, name, metric): 60 | with self.lock: 61 | key = self._compose_key(name) 62 | if key in self.metrics: 63 | raise RegistryException("{0} already present " 64 | "in the registry.".format(name)) 65 | else: 66 | self.metrics[key] = metric 67 | 68 | def add_or_get(self, name, klass): 69 | with self.lock: 70 | key = self._compose_key(name) 71 | metric = self.metrics.get(key) 72 | if metric is not None: 73 | if not isinstance(metric, klass): 74 | raise RegistryException("{0} is not of " 75 | "type {1}.".format(name, klass)) 76 | else: 77 | if inspect.isclass(klass): 78 | metric = klass() 79 | else: 80 | metric = klass 81 | self.metrics[key] = metric 82 | return metric 83 | 84 | def stop(self): 85 | self.clear() 86 | 87 | def _compose_key(self, name): 88 | if isinstance(name, dict): 89 | if 'name' not in name: 90 | raise ArgumentException('Tagged metric needs a name entry: ' 91 | + str(name)) 92 | else: 93 | name = {'name': name} 94 | return frozenset(name.items()) 95 | 96 | def _decompose_key(self, key): 97 | key = dict(key) 98 | name = key.pop('name') 99 | return (name, key if len(key) > 0 else None) 100 | 101 | def __iter__(self): 102 | with self.lock: 103 | for key, metric in self.metrics.items(): 104 | key = dict(key) 105 | yield key['name'], metric 106 | 107 | @property 108 | def with_tags(self): 109 | with self.lock: 110 | for key, metric in self.metrics.items(): 111 | key = self._decompose_key(key) 112 | yield key, metric 113 | 114 | 115 | registry = Registry() 116 | -------------------------------------------------------------------------------- /metrology/reporter/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- flake8: noqa -*- 2 | 3 | from metrology.reporter.graphite import GraphiteReporter 4 | from metrology.reporter.librato import LibratoReporter 5 | from metrology.reporter.logger import LoggerReporter 6 | 7 | 8 | __all__ = (GraphiteReporter, LibratoReporter, LoggerReporter) 9 | -------------------------------------------------------------------------------- /metrology/reporter/base.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | 3 | from metrology.registry import registry 4 | from metrology.utils.periodic import PeriodicTask 5 | 6 | 7 | class Reporter(PeriodicTask): 8 | def __init__(self, interval=60, *args, **options): 9 | self.registry = options.get('registry', registry) 10 | super(Reporter, self).__init__(interval=interval) 11 | atexit.register(self._exit) 12 | 13 | def task(self): 14 | self.write() 15 | 16 | def write(self): 17 | raise NotImplementedError 18 | 19 | def _exit(self): 20 | self.write() 21 | -------------------------------------------------------------------------------- /metrology/reporter/ganglia.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from ganglia import GMetric 3 | 4 | from metrology.instruments.counter import Counter 5 | from metrology.instruments.gauge import Gauge 6 | from metrology.instruments.histogram import Histogram 7 | from metrology.instruments.meter import Meter 8 | from metrology.reporter.base import Reporter 9 | 10 | 11 | class GangliaReporter(Reporter): 12 | """ 13 | A ganglia reporter that send metrics to ganglia :: 14 | 15 | reporter = GangliaReporter('Report Group Name', 16 | 'localhost', 17 | 8649, 18 | 'udp', 19 | interval=60) 20 | reporter.start() 21 | 22 | :param default_group_name: default group name for ganglia 23 | :param host: hostname of gmond 24 | :param port: port of gmond 25 | :param protocol: protocol for gmond sockets 26 | :param interval: time between each reporting 27 | """ 28 | 29 | def __init__(self, default_group_name, host, port, protocol="udp", 30 | *args, **kwargs): 31 | super(GangliaReporter, self).__init__(*args, **kwargs) 32 | self.default_group_name = default_group_name 33 | self.gmetric = GMetric("{0}://{1}:{2}".format(protocol, host, port)) 34 | self.groups = {} 35 | 36 | def set_group(self, metric_name, group_name): 37 | """Override the group name for certain metrics.""" 38 | self.groups[metric_name] = group_name 39 | 40 | def write(self): 41 | for name, metric in self.registry: 42 | if isinstance(metric, Meter): 43 | self.send(name, 'Count', metric.count, 'int32', 'count') 44 | self.send(name, 'One Minute Rate', metric.one_minute_rate, 45 | 'double', 'per second') 46 | self.send(name, 'Five Minute Rate', metric.five_minute_rate, 47 | 'double', 'per second') 48 | self.send(name, 'Fifteen Minute Rate', 49 | metric.fifteen_minute_rate, 'double', 'per second') 50 | self.send(name, 'Mean Rate', metric.mean_rate, 51 | 'double', 'per second') 52 | elif isinstance(metric, Gauge): 53 | self.send(name, 'Value', metric.value(), 'int32', 'value') 54 | elif isinstance(metric, Histogram): 55 | self.send(name, 'Count', metric.count, 'int32', 'count') 56 | self.send(name, 'Mean value', metric.mean, 57 | 'double', 'mean value') 58 | self.send(name, 'Variance', metric.variance, 59 | 'double', 'variance') 60 | elif isinstance(metric, Counter): 61 | self.send(name, 'Count', metric.count, 'int32', 'count') 62 | 63 | def send(self, name, tracker, value, kind, unit): 64 | if tracker: 65 | name = "{0} - {1}".format(name, tracker) 66 | group = self.groups.get(name, self.default_group_name) 67 | self.gmetric.send(name=name, value=value, type=kind, 68 | units=unit, group=group) 69 | -------------------------------------------------------------------------------- /metrology/reporter/graphite.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socket 3 | import pickle 4 | import struct 5 | import sys 6 | import string 7 | 8 | from metrology.instruments import ( 9 | Counter, 10 | Gauge, 11 | Histogram, 12 | Meter, 13 | Timer, 14 | UtilizationTimer 15 | ) 16 | from metrology.reporter.base import Reporter 17 | from metrology.utils import now 18 | 19 | 20 | class GraphiteReporter(Reporter): 21 | """ 22 | A graphite reporter that send metrics to graphite :: 23 | 24 | reporter = GraphiteReporter('graphite.local', 2003) 25 | reporter.start() 26 | 27 | :param host: hostname of graphite 28 | :param port: port of graphite 29 | :param interval: time between each reporting 30 | :param prefix: metrics name prefix 31 | """ 32 | 33 | def __init__(self, host, port, **options): 34 | self.host = host 35 | self.port = port 36 | 37 | self.prefix = options.get('prefix') 38 | self.pickle = options.get('pickle', False) 39 | self.batch_size = options.get('batch_size', 100) 40 | if self.batch_size <= 0: 41 | self.batch_size = 1 42 | super(GraphiteReporter, self).__init__(**options) 43 | self.batch_count = 0 44 | if self.pickle: 45 | self._buffered_send_metric = self._buffered_pickle_send_metric 46 | self._send = self._send_pickle 47 | self.batch_buffer = [] 48 | else: 49 | self._buffered_send_metric = self._buffered_plaintext_send_metric 50 | self._send = self._send_plaintext 51 | self.batch_buffer = "" 52 | 53 | self._compile_validation_regexes() 54 | 55 | def _compile_validation_regexes(self): 56 | # taken from graphite-web/webapp/graphite/render/grammar.py 57 | printables = "".join(c for c in string.printable 58 | if c not in string.whitespace) 59 | invalid_metric_chars = '''(){},.'"\\|''' 60 | # the '.' is needed because the regex is applied to complete pathes 61 | valid_metric_chars = ''.join((set(printables) 62 | - set(invalid_metric_chars)) | set('.')) 63 | invalid_chars = '[^%s]+' % re.escape(valid_metric_chars) 64 | self.invalid_metric_chars_regex = re.compile(invalid_chars) 65 | 66 | # taken from carbon/util.py TaggedSeries 67 | prohibited_tag_chars = ';!^=' 68 | valid_tag_chars = ''.join(set(printables) 69 | - set(invalid_metric_chars) 70 | - set(prohibited_tag_chars)) 71 | invalid_tag_chars = '[^%s]+' % re.escape(valid_tag_chars) 72 | self.invalid_tag_chars_regex = re.compile(invalid_tag_chars) 73 | 74 | @property 75 | def socket(self): 76 | if not hasattr(self, '_socket'): 77 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 78 | self._socket.connect((self.host, self.port)) 79 | return self._socket 80 | 81 | def write(self): 82 | for name, metric in self.registry.with_tags: 83 | 84 | if isinstance(metric, Meter): 85 | self.send_metric(name, 'meter', metric, [ 86 | 'count', 'one_minute_rate', 'five_minute_rate', 87 | 'fifteen_minute_rate', 'mean_rate' 88 | ]) 89 | if isinstance(metric, Gauge): 90 | self.send_metric(name, 'gauge', metric, [ 91 | 'value' 92 | ]) 93 | if isinstance(metric, UtilizationTimer): 94 | self.send_metric(name, 'timer', metric, [ 95 | 'count', 'one_minute_rate', 'five_minute_rate', 96 | 'fifteen_minute_rate', 'mean_rate', 97 | 'min', 'max', 'mean', 'stddev', 98 | 'one_minute_utilization', 'five_minute_utilization', 99 | 'fifteen_minute_utilization', 'mean_utilization' 100 | ], [ 101 | 'median', 'percentile_95th', 'percentile_99th', 102 | 'percentile_999th' 103 | ]) 104 | if isinstance(metric, Timer): 105 | self.send_metric(name, 'timer', metric, [ 106 | 'count', 'total_time', 'one_minute_rate', 107 | 'fifteen_minute_rate', 'mean_rate', 'five_minute_rate', 108 | 'min', 'max', 'mean', 'stddev' 109 | ], [ 110 | 'median', 'percentile_95th', 'percentile_99th', 111 | 'percentile_999th' 112 | ]) 113 | if isinstance(metric, Counter): 114 | self.send_metric(name, 'counter', metric, [ 115 | 'count' 116 | ]) 117 | if isinstance(metric, Histogram): 118 | self.send_metric(name, 'histogram', metric, [ 119 | 'count', 'min', 'max', 'mean', 'stddev', 120 | ], [ 121 | 'median', 'percentile_95th', 'percentile_99th', 122 | 'percentile_999th' 123 | ]) 124 | 125 | # Send metrics that might be in buffers 126 | self._send() 127 | 128 | def send_metric(self, name, type, metric, keys, snapshot_keys=None): 129 | if snapshot_keys is None: 130 | snapshot_keys = [] 131 | name, tags = name if isinstance(name, tuple) else (name, None) 132 | 133 | base_name = self.invalid_metric_chars_regex.sub("_", name) 134 | if self.prefix: 135 | base_name = "{0}.{1}".format(self.prefix, base_name) 136 | 137 | for name in keys: 138 | value = True 139 | value = getattr(metric, name) 140 | self._buffered_send_metric(base_name, name, tags, value, now()) 141 | 142 | if hasattr(metric, 'snapshot'): 143 | snapshot = metric.snapshot 144 | for name in snapshot_keys: 145 | value = True 146 | value = getattr(snapshot, name) 147 | self._buffered_send_metric(base_name, name, tags, value, now()) 148 | 149 | def _format_tag(self, tag, value): 150 | # tag must not be empty (taken from carbon/util.py) 151 | tag = tag if len(tag) > 0 else "empty_tag" 152 | tag = self.invalid_tag_chars_regex.sub('_', tag) 153 | 154 | value = str(value) 155 | # value must not be empty (taken from carbon/util.py) 156 | value = value if len(value) > 0 else "empty_value" 157 | # value must not contain ; and not start with ~ 158 | value = str(value).replace(';', '_') 159 | if value[0] == '~': 160 | value = '_' + value.lstrip('~') 161 | 162 | return '{0}={1}'.format(tag, value) 163 | 164 | def _format_metric_name(self, base_name, name, tags): 165 | metric_name = "{0}.{1}".format(base_name, name) 166 | if tags is not None: 167 | metric_name = '{0};{1}'.format( 168 | metric_name, 169 | ";".join([self._format_tag(tag, value) 170 | for tag, value in tags.items()])) 171 | return metric_name 172 | 173 | def _buffered_plaintext_send_metric(self, base_name, name, tags, value, t, 174 | force=False): 175 | self.batch_count += 1 176 | metric_name = self._format_metric_name(base_name, name, tags) 177 | self.batch_buffer += "{0} {1} {2}\n".format( 178 | metric_name, value, now()) 179 | # Check if we reach batch size and send 180 | if self.batch_count >= self.batch_size: 181 | self._send_plaintext() 182 | 183 | def _buffered_pickle_send_metric(self, base_name, name, tags, value, t): 184 | self.batch_count += 1 185 | metric_name = self._format_metric_name(base_name, name, tags) 186 | self.batch_buffer.append((metric_name, (t, value))) 187 | # Check if we reach batch size and send 188 | if self.batch_count >= self.batch_size: 189 | self._send_pickle() 190 | 191 | def _send_plaintext(self): 192 | if len(self.batch_buffer): 193 | if sys.version_info[0] > 2: 194 | self.socket.sendall(bytes(self.batch_buffer + '\n', 'ascii')) 195 | else: 196 | self.socket.sendall(self.batch_buffer + "\n") 197 | # Reinitialze buffer and counter 198 | self.batch_count = 0 199 | self.batch_buffer = "" 200 | 201 | def _send_pickle(self): 202 | if len(self.batch_buffer): 203 | payload = pickle.dumps(self.batch_buffer) 204 | header = struct.pack("!L", len(payload)) 205 | message = header + payload 206 | self.socket.sendall(message) 207 | # Reinitialze buffer and counter 208 | self.batch_count = 0 209 | self.batch_buffer = [] 210 | -------------------------------------------------------------------------------- /metrology/reporter/librato.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from json import dumps 4 | 5 | from metrology.exceptions import ReporterException 6 | from metrology.instruments import ( 7 | Counter, 8 | Gauge, 9 | Histogram, 10 | Meter, 11 | Timer, 12 | UtilizationTimer, 13 | ) 14 | from metrology.reporter.base import Reporter 15 | from metrology.utils import now 16 | 17 | 18 | class LibratoReporter(Reporter): 19 | """ 20 | A librato metrics reporter that send metrics to librato :: 21 | 22 | reporter = LibratoReporter("", "", source="front.local") 23 | reporter.start() 24 | 25 | :param email: your librato email 26 | :param token: your librato api token 27 | :param source: source of the metric 28 | :param interval: time between each reporting 29 | :param prefix: metrics name prefix 30 | :param filters: allow given keys to be send 31 | :param excludes: exclude given keys to be send 32 | """ 33 | def __init__(self, email, token, **options): 34 | self.email = email 35 | self.token = token 36 | 37 | try: 38 | import requests # noqa 39 | except Exception: 40 | raise ReporterException("Librato reporter requires the " 41 | "'requests' library") 42 | 43 | self.filters = options.get('filters') 44 | self.excludes = options.get('excludes') 45 | self.source = options.get('source') 46 | self.prefix = options.get('prefix') 47 | super(LibratoReporter, self).__init__(**options) 48 | 49 | def list_metrics(self): 50 | for name, metric in self.registry.with_tags: 51 | if isinstance(metric, Meter): 52 | yield self.prepare_metric(name, 'meter', metric, [ 53 | 'count', 'one_minute_rate', 'five_minute_rate', 54 | 'fifteen_minute_rate', 'mean_rate' 55 | ]) 56 | if isinstance(metric, Gauge): 57 | yield self.prepare_metric(name, 'gauge', metric, [ 58 | 'value' 59 | ]) 60 | if isinstance(metric, UtilizationTimer): 61 | yield self.prepare_metric(name, 'timer', metric, [ 62 | 'count', 'one_minute_rate', 'five_minute_rate', 63 | 'fifteen_minute_rate', 'mean_rate', 64 | 'min', 'max', 'mean', 'stddev', 65 | 'one_minute_utilization', 'five_minute_utilization', 66 | 'fifteen_minute_utilization', 'mean_utilization' 67 | ], [ 68 | 'median', 'percentile_95th' 69 | ]) 70 | if isinstance(metric, Timer): 71 | yield self.prepare_metric(name, 'timer', metric, [ 72 | 'count', 'total_time', 'one_minute_rate', 73 | 'five_minute_rate', 'fifteen_minute_rate', 'mean_rate', 74 | 'min', 'max', 'mean', 'stddev' 75 | ], [ 76 | 'median', 'percentile_95th' 77 | ]) 78 | if isinstance(metric, Counter): 79 | yield self.prepare_metric(name, 'counter', metric, [ 80 | 'count' 81 | ]) 82 | if isinstance(metric, Histogram): 83 | yield self.prepare_metric(name, 'histogram', metric, [ 84 | 'count', 'min', 'max', 'mean', 'stddev', 85 | ], [ 86 | 'median', 'percentile_95th' 87 | ]) 88 | 89 | def write(self): 90 | import requests 91 | metrics = { 92 | "gauges": [data for metric in self.list_metrics() 93 | for type, data in metric if type == "gauge"], 94 | "counters": [data for metric in self.list_metrics() 95 | for type, data in metric if type == "counter"] 96 | } 97 | requests.post("https://metrics-api.librato.com/v1/metrics", 98 | data=dumps(metrics), 99 | auth=(self.email, self.token), 100 | headers={'content-type': 'application/json'}) 101 | 102 | def prepare_metric(self, name, type, metric, keys, snapshot_keys=[]): 103 | name, tags = name if isinstance(name, tuple) else (name, None) 104 | base_name = re.sub(r"\s+", "_", name) 105 | if self.prefix: 106 | base_name = "{0}.{1}".format(self.prefix, base_name) 107 | 108 | time = now() 109 | type = "gauge" if type != "counter" else "counter" 110 | 111 | if self.filters: 112 | keys = filter(lambda key: key in self.filters, keys) 113 | snapshot_keys = filter(lambda key: key in self.filters, 114 | snapshot_keys) 115 | 116 | if self.excludes: 117 | keys = filter(lambda key: key not in self.excludes, 118 | keys) 119 | snapshot_keys = filter(lambda key: key in self.excludes, 120 | snapshot_keys) 121 | 122 | for name in keys: 123 | value = getattr(metric, name) 124 | data = { 125 | "name": "{0}.{1}".format(base_name, name), 126 | "source": self.source, 127 | "time": time, 128 | "value": value 129 | } 130 | if tags is not None: 131 | data['tags'] = tags 132 | yield type, data 133 | 134 | if hasattr(metric, 'snapshot'): 135 | snapshot = metric.snapshot 136 | for name in snapshot_keys: 137 | value = getattr(snapshot, name) 138 | data = { 139 | "name": "{0}.{1}".format(base_name, name), 140 | "source": self.source, 141 | "time": time, 142 | "value": value 143 | } 144 | if tags is not None: 145 | data['tags'] = tags 146 | yield type, data 147 | -------------------------------------------------------------------------------- /metrology/reporter/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from metrology.instruments import ( 4 | Counter, 5 | Gauge, 6 | Histogram, 7 | Meter, 8 | Timer, 9 | UtilizationTimer 10 | ) 11 | from metrology.reporter.base import Reporter 12 | 13 | 14 | class LoggerReporter(Reporter): 15 | """ 16 | A logging reporter that write metrics to a logger :: 17 | 18 | reporter = LoggerReporter(level=logging.DEBUG, interval=10) 19 | reporter.start() 20 | 21 | :param logger: logger to use 22 | :param level: logger level 23 | :param interval: time between each reporting 24 | :param prefix: metrics name prefix 25 | """ 26 | def __init__(self, logger=logging, level=logging.INFO, **options): 27 | self.logger = logger 28 | self.level = level 29 | 30 | self.prefix = options.get('prefix') 31 | super(LoggerReporter, self).__init__(**options) 32 | 33 | def write(self): 34 | for name, metric in self.registry.with_tags: 35 | if isinstance(metric, Meter): 36 | self.log_metric(name, 'meter', metric, [ 37 | 'count', 'one_minute_rate', 'five_minute_rate', 38 | 'fifteen_minute_rate', 'mean_rate' 39 | ]) 40 | if isinstance(metric, Gauge): 41 | self.log_metric(name, 'gauge', metric, [ 42 | 'value' 43 | ]) 44 | if isinstance(metric, UtilizationTimer): 45 | self.log_metric(name, 'timer', metric, [ 46 | 'count', 'one_minute_rate', 'five_minute_rate', 47 | 'fifteen_minute_rate', 'mean_rate', 48 | 'min', 'max', 'mean', 'stddev', 49 | 'one_minute_utilization', 'five_minute_utilization', 50 | 'fifteen_minute_utilization', 'mean_utilization' 51 | ], [ 52 | 'median', 'percentile_95th' 53 | ]) 54 | if isinstance(metric, Timer): 55 | self.log_metric(name, 'timer', metric, [ 56 | 'count', 'total_time', 'one_minute_rate', 57 | 'five_minute_rate', 'fifteen_minute_rate', 'mean_rate', 58 | 'min', 'max', 'mean', 'stddev' 59 | ], [ 60 | 'median', 'percentile_95th' 61 | ]) 62 | if isinstance(metric, Counter): 63 | self.log_metric(name, 'counter', metric, [ 64 | 'count' 65 | ]) 66 | if isinstance(metric, Histogram): 67 | self.log_metric(name, 'histogram', metric, [ 68 | 'count', 'min', 'max', 'mean', 'stddev', 69 | ], [ 70 | 'median', 'percentile_95th' 71 | ]) 72 | 73 | def log_metric(self, name, type, metric, keys, snapshot_keys=None): 74 | if snapshot_keys is None: 75 | snapshot_keys = [] 76 | name, tags = name if isinstance(name, tuple) else (name, None) 77 | messages = [] 78 | if self.prefix: 79 | messages.append(self.prefix) 80 | 81 | messages.append(name) 82 | messages.append(type) 83 | 84 | if tags is not None: 85 | tag_msg = ", ".join(["{0}={1}".format(k, v) 86 | for k, v in tags.items()]) 87 | tag_msg = "[{0}]".format(tag_msg) 88 | messages.append(tag_msg) 89 | 90 | for name in keys: 91 | messages.append("{0}={1}".format(name, getattr(metric, name))) 92 | 93 | if hasattr(metric, 'snapshot'): 94 | snapshot = metric.snapshot 95 | for name in snapshot_keys: 96 | messages.append("{0}={1}".format(name, 97 | getattr(snapshot, name))) 98 | 99 | self.logger.log(self.level, " ".join(messages)) 100 | -------------------------------------------------------------------------------- /metrology/reporter/statsd.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import sys 3 | 4 | from metrology.instruments import ( 5 | Counter, 6 | Gauge, 7 | Histogram, 8 | Meter, 9 | Timer, 10 | UtilizationTimer 11 | ) 12 | from metrology.reporter.base import Reporter 13 | 14 | 15 | def class_name(obj): 16 | return obj.__name__ if isinstance(obj, type) else type(obj).__name__ 17 | 18 | 19 | def mmap(func, iterable): 20 | """Wrapper to make map() behave the same on Py2 and Py3.""" 21 | 22 | if sys.version_info[0] > 2: 23 | return [i for i in map(func, iterable)] 24 | else: 25 | return map(func, iterable) 26 | 27 | 28 | # NOTE(romcheg): This dictionary maps metric types to specific configuration 29 | # of the metric serializer. 30 | # Format: 31 | # { 32 | # 'metric_type': 33 | # { 34 | # 'serialized_type': str, 35 | # 'keys': list(str), 36 | # 'snapshot_keys': list(str) 37 | # } 38 | # } 39 | SERIALIZER_CONFIG = { 40 | class_name(Meter): { 41 | 'serialized_type': 'm', 42 | 'keys': [ 43 | 'count', 'one_minute_rate', 'five_minute_rate', 'mean_rate', 44 | 'fifteen_minute_rate' 45 | ], 46 | 'snapshot_keys': None 47 | }, 48 | 49 | class_name(Gauge): { 50 | 'serialized_type': 'g', 51 | 'keys': ['value'], 52 | 'snapshot_keys': None 53 | }, 54 | 55 | class_name(UtilizationTimer): { 56 | 'serialized_type': 'ms', 57 | 'keys': [ 58 | 'count', 'one_minute_rate', 'five_minute_rate', 'min', 'max', 59 | 'fifteen_minute_rate', 'mean_rate', 'mean', 'stddev', 60 | 'one_minute_utilization', 'five_minute_utilization', 61 | 'fifteen_minute_utilization', 'mean_utilization' 62 | ], 63 | 'snapshot_keys': [ 64 | 'median', 'percentile_95th', 'percentile_99th', 'percentile_999th' 65 | ] 66 | }, 67 | 68 | class_name(Timer): { 69 | 'serialized_type': 'ms', 70 | 'keys': [ 71 | 'count', 'total_time', 'one_minute_rate', 'five_minute_rate', 72 | 'fifteen_minute_rate', 'mean_rate', 'min', 'max', 'mean', 'stddev' 73 | ], 74 | 'snapshot_keys': [ 75 | 'median', 'percentile_95th', 'percentile_99th', 'percentile_999th' 76 | ] 77 | }, 78 | 79 | class_name(Counter): { 80 | 'serialized_type': 'c', 81 | 'keys': ['count'], 82 | 'snapshot_keys': None 83 | }, 84 | 85 | class_name(Histogram): { 86 | 'serialized_type': 'h', 87 | 'keys': ['count', 'min', 'max', 'mean', 'stddev'], 88 | 'snapshot_keys': [ 89 | 'median', 'percentile_95th', 'percentile_99th', 'percentile_999th' 90 | ] 91 | } 92 | } 93 | 94 | 95 | class StatsDReporter(Reporter): 96 | """ 97 | A statsd reporter that sends metrics to statsd daemon :: 98 | 99 | reporter = StatsDReporter('statsd.local', 8125) 100 | reporter.start() 101 | 102 | :param host: hostname of statsd daemon 103 | :param port: port of daemon 104 | :param interval: time between each reports 105 | :param prefix: metrics name prefix 106 | 107 | """ 108 | def __init__(self, host, port, conn_type='udp', **options): 109 | self.host = host 110 | self.port = port 111 | self.conn_type = conn_type 112 | 113 | self.prefix = options.get('prefix') 114 | self.batch_size = options.get('batch_size', 100) 115 | self.batch_buffer = '' 116 | if self.batch_size <= 0: 117 | self.batch_size = 1 118 | self._socket = None 119 | super(StatsDReporter, self).__init__(**options) 120 | self.batch_count = 0 121 | if conn_type == 'tcp': 122 | self._send = self._send_tcp 123 | else: 124 | self._send = self._send_udp 125 | 126 | @property 127 | def socket(self): 128 | if not self._socket: 129 | if self.conn_type == 'tcp': 130 | self._socket = socket.socket(socket.AF_INET, 131 | socket.SOCK_STREAM) 132 | self._socket.connect((self.host, self.port)) 133 | else: 134 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 135 | return self._socket 136 | 137 | def write(self): 138 | for name, metric in self.registry.with_tags: 139 | 140 | if self._is_metric_supported(metric): 141 | self.send_metric(name, metric) 142 | 143 | self._send() 144 | 145 | def send_metric(self, name, metric): 146 | """Send metric and its snapshot.""" 147 | config = SERIALIZER_CONFIG[class_name(metric)] 148 | 149 | mmap( 150 | self._buffered_send_metric, 151 | self.serialize_metric( 152 | metric, 153 | name, 154 | config['keys'], 155 | config['serialized_type'] 156 | ) 157 | ) 158 | 159 | if hasattr(metric, 'snapshot') and config.get('snapshot_keys'): 160 | mmap( 161 | self._buffered_send_metric, 162 | self.serialize_metric( 163 | metric.snapshot, 164 | name, 165 | config['snapshot_keys'], 166 | config['serialized_type'] 167 | ) 168 | ) 169 | 170 | def serialize_metric(self, metric, m_name, keys, m_type): 171 | """Serialize and send available measures of a metric.""" 172 | 173 | return [ 174 | self.format_metric_string(m_name, getattr(metric, key), m_type) 175 | for key in keys 176 | ] 177 | 178 | def format_metric_string(self, name, value, m_type): 179 | """Compose a statsd compatible string for a metric's measurement.""" 180 | 181 | # NOTE(romcheg): This serialized metric template is based on 182 | # statsd's documentation. 183 | template = '{name}:{value}|{m_type}\n' 184 | 185 | name, tags = name if isinstance(name, tuple) else (name, None) 186 | 187 | if self.prefix: 188 | name = "{prefix}.{m_name}".format(prefix=self.prefix, m_name=name) 189 | 190 | if tags: 191 | tags = ",".join(["{0}={1}".format(k, v) for k, v in tags.items()]) 192 | name = "{name},{tags}".format(name=name, tags=tags) 193 | 194 | return template.format(name=name, value=value, m_type=m_type) 195 | 196 | def _buffered_send_metric(self, metric_str): 197 | """Add a metric to the buffer.""" 198 | 199 | self.batch_count += 1 200 | 201 | self.batch_buffer += metric_str 202 | 203 | # NOTE(romcheg): Send metrics if the number of metrics in the buffer 204 | # has reached the threshold for sending. 205 | if self.batch_count >= self.batch_size: 206 | self._send() 207 | 208 | def _is_metric_supported(self, metric): 209 | return class_name(metric) in SERIALIZER_CONFIG 210 | 211 | def _send_tcp(self): 212 | if len(self.batch_buffer): 213 | if sys.version_info[0] > 2: 214 | self.socket.sendall(bytes(self.batch_buffer, 'ascii')) 215 | else: 216 | self.socket.sendall(self.batch_buffer) 217 | 218 | self.batch_count = 0 219 | self.batch_buffer = '' 220 | 221 | def _send_udp(self): 222 | if len(self.batch_buffer): 223 | if sys.version_info[0] > 2: 224 | self.socket.sendto(bytes(self.batch_buffer, 'ascii'), 225 | (self.host, self.port)) 226 | else: 227 | self.socket.sendto(self.batch_buffer, 228 | (self.host, self.port)) 229 | 230 | self.batch_count = 0 231 | self.batch_buffer = '' 232 | -------------------------------------------------------------------------------- /metrology/stats/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- flake8: noqa -*- 2 | from metrology.stats.ewma import EWMA 3 | from metrology.stats.sample import UniformSample, ExponentiallyDecayingSample 4 | from metrology.stats.snapshot import Snapshot 5 | 6 | 7 | __all__ = ( 8 | EWMA, 9 | ExponentiallyDecayingSample, 10 | Snapshot, 11 | UniformSample 12 | ) 13 | -------------------------------------------------------------------------------- /metrology/stats/ewma.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from atomic import AtomicLong 4 | 5 | 6 | class EWMA(object): 7 | INTERVAL = 5.0 8 | SECONDS_PER_MINUTE = 60.0 9 | 10 | ONE_MINUTE = 1 11 | FIVE_MINUTES = 5 12 | FIFTEEN_MINUTES = 15 13 | 14 | M1_ALPHA = 1 - math.exp(-INTERVAL / SECONDS_PER_MINUTE / ONE_MINUTE) 15 | M5_ALPHA = 1 - math.exp(-INTERVAL / SECONDS_PER_MINUTE / FIVE_MINUTES) 16 | M15_ALPHA = 1 - math.exp(-INTERVAL / SECONDS_PER_MINUTE / FIFTEEN_MINUTES) 17 | 18 | @classmethod 19 | def m1(cls): 20 | return EWMA(cls.M1_ALPHA, cls.INTERVAL) 21 | 22 | @classmethod 23 | def m5(cls): 24 | return EWMA(cls.M5_ALPHA, cls.INTERVAL) 25 | 26 | @classmethod 27 | def m15(cls): 28 | return EWMA(cls.M15_ALPHA, cls.INTERVAL) 29 | 30 | def __init__(self, alpha, interval): 31 | self.alpha = alpha 32 | self.interval = interval 33 | 34 | self.initialized = False 35 | self._rate = 0.0 36 | self._uncounted = AtomicLong(0) 37 | 38 | def clear(self): 39 | self.initialized = False 40 | self._rate = 0.0 41 | self._uncounted.value = 0 42 | 43 | def update(self, value): 44 | self._uncounted += value 45 | 46 | def tick(self): 47 | count = self._uncounted.swap(0) 48 | instant_rate = count / self.interval 49 | 50 | if self.initialized: 51 | self._rate += self.alpha * (instant_rate - self._rate) 52 | else: 53 | self._rate = instant_rate 54 | self.initialized = True 55 | 56 | @property 57 | def rate(self): 58 | return self._rate 59 | -------------------------------------------------------------------------------- /metrology/stats/sample.py: -------------------------------------------------------------------------------- 1 | import heapq 2 | import math 3 | import random 4 | import sys 5 | 6 | from threading import RLock 7 | 8 | from atomic import AtomicLong 9 | 10 | from metrology.stats.snapshot import Snapshot 11 | from metrology.utils import now 12 | 13 | 14 | class UniformSample(object): 15 | def __init__(self, reservoir_size): 16 | self.counter = AtomicLong(0) 17 | self.values = [0] * reservoir_size 18 | 19 | def clear(self): 20 | self.values = [0] * len(self.values) 21 | self.counter.value = 0 22 | 23 | def size(self): 24 | count = self.counter.value 25 | if count > len(self.values): 26 | return len(self.values) 27 | return count 28 | 29 | def __len__(self): 30 | return self.size 31 | 32 | def snapshot(self): 33 | return Snapshot(self.values[0:self.size()]) 34 | 35 | def update(self, value): 36 | self.counter += 1 37 | new_count = self.counter.value 38 | 39 | if new_count <= len(self.values): 40 | self.values[new_count - 1] = value 41 | else: 42 | index = random.uniform(0, new_count) 43 | if index < len(self.values): 44 | self.values[int(index)] = value 45 | 46 | 47 | class ExponentiallyDecayingSample(object): 48 | def __init__(self, reservoir_size, alpha): 49 | self.values = [] 50 | self.next_scale_time = AtomicLong(0) 51 | self.alpha = alpha 52 | self.reservoir_size = reservoir_size 53 | self.lock = RLock() 54 | self.rescale_threshold = \ 55 | ExponentiallyDecayingSample.calculate_rescale_threshold(alpha) 56 | self.clear() 57 | 58 | @staticmethod 59 | def calculate_rescale_threshold(alpha): 60 | # determine rescale-threshold such that we will not overflow exp() in 61 | # weight function, and subsequently not overflow into inf on dividing 62 | # by random.random() 63 | min_rand = 1.0 / (2 ** 32) # minimum non-zero value from random() 64 | safety = 2.0 # safety pad for numerical inaccuracy 65 | max_value = sys.float_info.max * min_rand / safety 66 | return int(math.log(max_value) / alpha) 67 | 68 | def clear(self): 69 | with self.lock: 70 | self.values = [] 71 | self.start_time = now() 72 | self.next_scale_time.value = \ 73 | self.start_time + self.rescale_threshold 74 | 75 | def size(self): 76 | with self.lock: 77 | return len(self.values) 78 | 79 | def __len__(self): 80 | return self.size() 81 | 82 | def snapshot(self): 83 | with self.lock: 84 | return Snapshot(val for _, val in self.values) 85 | 86 | def weight(self, timestamp): 87 | return math.exp(self.alpha * (timestamp - self.start_time)) 88 | 89 | def rescale(self, now, next_time): 90 | if self.next_scale_time.compare_and_swap( 91 | next_time, now + self.rescale_threshold 92 | ): 93 | with self.lock: 94 | rescaleFactor = math.exp(-self.alpha * (now - self.start_time)) 95 | self.values = [(k * rescaleFactor, v) for k, v in self.values] 96 | self.start_time = now 97 | 98 | def rescale_if_necessary(self): 99 | time = now() 100 | next_time = self.next_scale_time.value 101 | if time > next_time: 102 | self.rescale(time, next_time) 103 | 104 | def update(self, value, timestamp=None): 105 | if timestamp is None: 106 | timestamp = now() 107 | 108 | self.rescale_if_necessary() 109 | with self.lock: 110 | try: 111 | priority = self.weight(timestamp) / random.random() 112 | except (OverflowError, ZeroDivisionError): 113 | priority = sys.float_info.max 114 | 115 | if len(self.values) < self.reservoir_size: 116 | heapq.heappush(self.values, (priority, value)) 117 | else: 118 | heapq.heappushpop(self.values, (priority, value)) 119 | -------------------------------------------------------------------------------- /metrology/stats/snapshot.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from metrology.exceptions import ArgumentException 4 | 5 | 6 | class Snapshot(object): 7 | MEDIAN_Q = 0.5 8 | P75_Q = 0.75 9 | P95_Q = 0.95 10 | P98_Q = 0.98 11 | P99_Q = 0.99 12 | P999_Q = 0.999 13 | 14 | def __init__(self, values): 15 | self.values = sorted(values) 16 | 17 | def value(self, quantile): 18 | if 0.0 > quantile > 1.0: 19 | raise ArgumentException("Quantile must be between 0.0 and 1.0") 20 | 21 | if not self.values: 22 | return 0.0 23 | 24 | pos = quantile * (len(self.values) + 1) 25 | 26 | if pos < 1: 27 | return self.values[0] 28 | 29 | if pos >= len(self.values): 30 | return self.values[-1] 31 | 32 | lower = self.values[int(pos) - 1] 33 | upper = self.values[int(pos)] 34 | return lower + (pos - math.floor(pos)) * (upper - lower) 35 | 36 | def size(self): 37 | return len(self.values) 38 | 39 | def __len__(self): 40 | return self.size() 41 | 42 | @property 43 | def median(self): 44 | return self.value(self.MEDIAN_Q) 45 | 46 | @property 47 | def percentile_75th(self): 48 | return self.value(self.P75_Q) 49 | 50 | @property 51 | def percentile_95th(self): 52 | return self.value(self.P95_Q) 53 | 54 | @property 55 | def percentile_98th(self): 56 | return self.value(self.P98_Q) 57 | 58 | @property 59 | def percentile_99th(self): 60 | return self.value(self.P99_Q) 61 | 62 | @property 63 | def percentile_999th(self): 64 | return self.value(self.P999_Q) 65 | -------------------------------------------------------------------------------- /metrology/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | 4 | def now(): 5 | return int(time()) 6 | -------------------------------------------------------------------------------- /metrology/utils/periodic.py: -------------------------------------------------------------------------------- 1 | from threading import Thread, Event 2 | 3 | 4 | class PeriodicTask(Thread): 5 | def __init__(self, interval=5., target=None): 6 | super(PeriodicTask, self).__init__() 7 | self.status = Event() 8 | self.interval = interval 9 | self.target = target 10 | self.daemon = True 11 | 12 | def stop(self): 13 | self.status.set() 14 | 15 | @property 16 | def stopped(self): 17 | return self.status.isSet() 18 | 19 | def run(self): 20 | while True: 21 | if self.stopped: 22 | return 23 | self.status.wait(self.interval) 24 | self.task() 25 | 26 | def task(self): 27 | if not self.target: 28 | raise NotImplementedError 29 | self.target() 30 | -------------------------------------------------------------------------------- /metrology/wsgi.py: -------------------------------------------------------------------------------- 1 | from metrology import Metrology 2 | 3 | 4 | class Middleware(object): 5 | """ 6 | A WSGI middleware to measure requests rate and time :: 7 | 8 | application = Middleware(application, reporter) 9 | 10 | :param application: your wsgi application 11 | :param reporter: your metrology reporter 12 | """ 13 | def __init__(self, application, reporter=None, **kwargs): 14 | self.application = application 15 | self.request = Metrology.meter('request') 16 | self.request_time = Metrology.timer('request_time') 17 | 18 | # Start reporter 19 | if reporter: 20 | reporter.start() 21 | 22 | def __call__(self, environ, start_response): 23 | self.request.mark() 24 | with self.request_time: 25 | return self.application(environ, start_response) 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | astrolabe>=0.4.0 2 | atomic>=0.7.0 3 | ganglia 4 | requests 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = metrology 3 | version = 1.3.0 4 | summary = A library to easily measure what\'s going on in your python. 5 | description-file = README.rst 6 | author = Timothée Peignier' 7 | author_email = timothee.peignier@tryphon.org 8 | home-page = https://github.com/cyberdelia/metrology 9 | license = MIT License 10 | 11 | classifiers= 12 | Intended Audience :: Developers 13 | License :: OSI Approved :: MIT License 14 | Operating System :: OS Independent 15 | Programming Language :: Python 16 | Programming Language :: Python :: 3 17 | Topic :: Utilities 18 | 19 | [wheel] 20 | universal = 1 21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup 3 | 4 | 5 | with open('LICENSE') as f: 6 | license = f.read() 7 | 8 | setup( 9 | setup_requires=['pbr>=1.9', 'setuptools>=17.1'], 10 | pbr=True, 11 | ) 12 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | mock 3 | pytest 4 | -------------------------------------------------------------------------------- /tests/instruments/test_counter.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from metrology.instruments.counter import Counter 4 | 5 | 6 | class CounterTest(TestCase): 7 | def setUp(self): 8 | self.counter = Counter() 9 | 10 | def test_increment(self): 11 | self.counter.increment() 12 | self.assertEqual(1, self.counter.count) 13 | 14 | def test_increment_more(self): 15 | self.counter.increment(10) 16 | self.assertEqual(10, self.counter.count) 17 | 18 | def test_clear(self): 19 | self.counter.increment(10) 20 | self.counter.clear() 21 | self.assertEqual(0, self.counter.count) 22 | 23 | def test_decrement(self): 24 | self.counter.increment(10) 25 | self.counter.decrement() 26 | self.assertEqual(9, self.counter.count) 27 | 28 | def test_decrement_more(self): 29 | self.counter.increment(10) 30 | self.counter.decrement(9) 31 | self.assertEqual(1, self.counter.count) 32 | -------------------------------------------------------------------------------- /tests/instruments/test_derive.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from metrology.instruments.derive import Derive 4 | 5 | 6 | class DeriveTest(TestCase): 7 | def setUp(self): 8 | self.derive = Derive() 9 | 10 | def test_derive(self): 11 | self.derive.mark() 12 | self.assertEqual(1, self.derive.count) 13 | 14 | def test_blank_derive(self): 15 | self.assertEqual(0, self.derive.count) 16 | self.assertEqual(0.0, self.derive.mean_rate) 17 | 18 | def test_derive_value(self): 19 | self.derive.mark(3) 20 | self.assertEqual(3, self.derive.count) 21 | 22 | def test_one_minute_rate(self): 23 | self.derive.mark(1000) 24 | self.derive.tick() 25 | self.assertEqual(200, self.derive.one_minute_rate) 26 | 27 | def tearDown(self): 28 | self.derive.stop() 29 | -------------------------------------------------------------------------------- /tests/instruments/test_gauge.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from unittest import TestCase 4 | 5 | from metrology.instruments.gauge import Gauge, RatioGauge, PercentGauge, \ 6 | ToggleGauge 7 | 8 | 9 | class DummyGauge(Gauge): 10 | @property 11 | def value(self): 12 | return "wow" 13 | 14 | 15 | class DummyRatioGauge(RatioGauge): 16 | def __init__(self, numerator, denominator): 17 | self.num = numerator 18 | self.den = denominator 19 | 20 | def numerator(self): 21 | return self.num 22 | 23 | def denominator(self): 24 | return self.den 25 | 26 | 27 | class DummyPercentGauge(DummyRatioGauge, PercentGauge): 28 | pass 29 | 30 | 31 | class GaugeTest(TestCase): 32 | def setUp(self): 33 | self.gauge = DummyGauge() 34 | 35 | def test_return_value(self): 36 | self.assertEqual(self.gauge.value, "wow") 37 | 38 | 39 | class RatioGaugeTest(TestCase): 40 | def test_ratio(self): 41 | gauge = DummyRatioGauge(2, 4) 42 | self.assertEqual(gauge.value, 0.5) 43 | 44 | def test_divide_by_zero(self): 45 | gauge = DummyRatioGauge(100, 0) 46 | self.assertTrue(math.isnan(gauge.value)) 47 | 48 | def test_divide_by_infinite(self): 49 | gauge = DummyRatioGauge(100, float('inf')) 50 | self.assertTrue(math.isnan(gauge.value)) 51 | 52 | def test_divide_by_nan(self): 53 | gauge = DummyRatioGauge(100, float('nan')) 54 | self.assertTrue(math.isnan(gauge.value)) 55 | 56 | 57 | class PercentGaugeTest(TestCase): 58 | def test_percentage(self): 59 | gauge = DummyPercentGauge(2, 4) 60 | self.assertEqual(gauge.value, 50.) 61 | 62 | def test_with_nan(self): 63 | gauge = DummyPercentGauge(2, 0) 64 | self.assertTrue(math.isnan(gauge.value)) 65 | 66 | 67 | class ToggleGaugeTest(TestCase): 68 | def test_return_one_then_zero(self): 69 | gauge = ToggleGauge() 70 | self.assertEqual(gauge.value, 1) 71 | self.assertEqual(gauge.value, 0) 72 | self.assertEqual(gauge.value, 0) 73 | self.assertEqual(gauge.value, 0) 74 | -------------------------------------------------------------------------------- /tests/instruments/test_healthcheck.py: -------------------------------------------------------------------------------- 1 | 2 | from unittest import TestCase 3 | 4 | from metrology.instruments.healthcheck import HealthCheck 5 | 6 | 7 | class DummyHealthCheck(HealthCheck): 8 | def check(self): 9 | return True 10 | 11 | 12 | class HealthCheckTest(TestCase): 13 | def setUp(self): 14 | self.health_check = DummyHealthCheck() 15 | 16 | def test_return_check(self): 17 | self.assertEqual(self.health_check.check(), True) 18 | -------------------------------------------------------------------------------- /tests/instruments/test_histogram.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from unittest import TestCase 3 | 4 | from metrology.stats.sample import ExponentiallyDecayingSample 5 | from metrology.instruments.histogram import Histogram, HistogramUniform, \ 6 | HistogramExponentiallyDecaying 7 | 8 | 9 | class HistogramTest(TestCase): 10 | def test_uniform_sample_total(self): 11 | histogram = HistogramUniform() 12 | histogram.update(5) 13 | histogram.update(10) 14 | self.assertEqual(15, histogram.total) 15 | 16 | def test_uniform_sample_min(self): 17 | histogram = HistogramUniform() 18 | histogram.update(5) 19 | histogram.update(10) 20 | self.assertEqual(5, histogram.min) 21 | 22 | def test_uniform_sample_max(self): 23 | histogram = HistogramUniform() 24 | histogram.update(5) 25 | histogram.update(10) 26 | self.assertEqual(10, histogram.max) 27 | 28 | def test_uniform_sample_mean(self): 29 | histogram = HistogramUniform() 30 | histogram.update(5) 31 | histogram.update(10) 32 | self.assertEqual(7.5, histogram.mean) 33 | 34 | def test_uniform_sample_mean_threaded(self): 35 | histogram = HistogramUniform() 36 | 37 | def update(): 38 | for i in range(100): 39 | histogram.update(5) 40 | histogram.update(10) 41 | for thread in [Thread(target=update) for i in range(10)]: 42 | thread.start() 43 | thread.join() 44 | self.assertEqual(7.5, histogram.mean) 45 | 46 | def test_uniform_sample_2000(self): 47 | histogram = HistogramUniform() 48 | for i in range(2000): 49 | histogram.update(i) 50 | self.assertEqual(1999, histogram.max) 51 | 52 | def test_uniform_sample_snapshot(self): 53 | histogram = HistogramUniform() 54 | for i in range(100): 55 | histogram.update(i) 56 | snapshot = histogram.snapshot 57 | self.assertEqual(49.5, snapshot.median) 58 | 59 | def test_uniform_sample_snapshot_threaded(self): 60 | histogram = HistogramUniform() 61 | 62 | def update(): 63 | for i in range(100): 64 | histogram.update(i) 65 | for thread in [Thread(target=update) for i in range(10)]: 66 | thread.start() 67 | thread.join() 68 | snapshot = histogram.snapshot 69 | self.assertEqual(49.5, snapshot.median) 70 | 71 | def test_exponential_sample_min(self): 72 | histogram = HistogramExponentiallyDecaying() 73 | histogram.update(5) 74 | histogram.update(10) 75 | self.assertEqual(5, histogram.min) 76 | 77 | def test_exponential_sample_max(self): 78 | histogram = HistogramExponentiallyDecaying() 79 | histogram.update(5) 80 | histogram.update(10) 81 | self.assertEqual(10, histogram.max) 82 | 83 | def test_exponential_sample_mean(self): 84 | histogram = HistogramExponentiallyDecaying() 85 | histogram.update(5) 86 | histogram.update(10) 87 | self.assertEqual(7.5, histogram.mean) 88 | 89 | def test_exponential_sample_mean_threaded(self): 90 | histogram = HistogramExponentiallyDecaying() 91 | 92 | def update(): 93 | for i in range(100): 94 | histogram.update(5) 95 | histogram.update(10) 96 | for thread in [Thread(target=update) for i in range(10)]: 97 | thread.start() 98 | thread.join() 99 | self.assertEqual(7.5, histogram.mean) 100 | 101 | def test_exponential_sample_2000(self): 102 | histogram = HistogramExponentiallyDecaying() 103 | for i in range(2000): 104 | histogram.update(i) 105 | self.assertEqual(1999, histogram.max) 106 | 107 | def test_exponential_sample_snapshot(self): 108 | histogram = HistogramExponentiallyDecaying() 109 | for i in range(100): 110 | histogram.update(i) 111 | snapshot = histogram.snapshot 112 | self.assertEqual(49.5, snapshot.median) 113 | 114 | def test_exponential_sample_snapshot_threaded(self): 115 | histogram = HistogramExponentiallyDecaying() 116 | 117 | def update(): 118 | for i in range(100): 119 | histogram.update(i) 120 | for thread in [Thread(target=update) for i in range(10)]: 121 | thread.start() 122 | thread.join() 123 | snapshot = histogram.snapshot 124 | self.assertEqual(49.5, snapshot.median) 125 | 126 | def test_sample_overflow_error(self): 127 | sample = ExponentiallyDecayingSample(Histogram.DEFAULT_SAMPLE_SIZE, 128 | Histogram.DEFAULT_ALPHA) 129 | sample.start_time = 946681200.0 130 | histogram = Histogram(sample) 131 | histogram.update(5) 132 | self.assertEqual(5, histogram.min) 133 | 134 | def test_clear(self): 135 | histogram = HistogramExponentiallyDecaying() 136 | histogram.clear() 137 | histogram.update(5) 138 | histogram.update(15) 139 | self.assertEqual(5, histogram.min) 140 | self.assertEqual(15, histogram.max) 141 | -------------------------------------------------------------------------------- /tests/instruments/test_meter.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from unittest import TestCase 3 | 4 | from metrology.instruments.meter import Meter 5 | 6 | 7 | class MeterTest(TestCase): 8 | def setUp(self): 9 | self.meter = Meter() 10 | 11 | def test_meter(self): 12 | self.meter.mark() 13 | self.assertEqual(1, self.meter.count) 14 | 15 | def test_blank_meter(self): 16 | self.assertEqual(0, self.meter.count) 17 | self.assertEqual(0.0, self.meter.mean_rate) 18 | 19 | def test_meter_value(self): 20 | self.meter.mark(3) 21 | self.assertEqual(3, self.meter.count) 22 | 23 | def test_one_minute_rate(self): 24 | self.meter.mark(1000) 25 | self.meter.tick() 26 | self.assertEqual(200, self.meter.one_minute_rate) 27 | 28 | def test_meter_threaded(self): 29 | def mark(): 30 | for i in range(100): 31 | self.meter.mark() 32 | for thread in [Thread(target=mark) for i in range(10)]: 33 | thread.start() 34 | thread.join() 35 | self.assertEqual(1000, self.meter.count) 36 | 37 | def test_meter_decorator(self): 38 | expected_return_value = 'meter' 39 | 40 | @self.meter 41 | def _test_decorator(): 42 | return expected_return_value 43 | 44 | for i in range(500): 45 | self.assertEqual(expected_return_value, _test_decorator()) 46 | self.assertEqual(500, self.meter.count) 47 | 48 | def test_meter_context_manager(self): 49 | for i in range(275): 50 | with self.meter: 51 | pass 52 | self.assertEqual(275, self.meter.count) 53 | 54 | def tearDown(self): 55 | self.meter.stop() 56 | -------------------------------------------------------------------------------- /tests/instruments/test_timer.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from unittest import TestCase 4 | 5 | from metrology.instruments.timer import Timer, UtilizationTimer 6 | 7 | 8 | class TimerTest(TestCase): 9 | def setUp(self): 10 | self.timer = Timer() 11 | 12 | def tearDown(self): 13 | self.timer.stop() 14 | 15 | def test_timer(self): 16 | for i in range(3): 17 | with self.timer: 18 | time.sleep(0.1) 19 | self.assertAlmostEqual(300, self.timer.total_time, delta=20) 20 | self.assertAlmostEqual(100, self.timer.mean, delta=10) 21 | self.assertAlmostEqual(100, self.timer.snapshot.median, delta=10) 22 | 23 | def test_timer_decorator(self): 24 | @self.timer 25 | def _test_decorator(): 26 | time.sleep(0.075) 27 | 28 | self.timer.clear() 29 | for i in range(3): 30 | _test_decorator() 31 | 32 | self.assertAlmostEqual(75, self.timer.mean, delta=10) 33 | self.assertAlmostEqual(75, self.timer.snapshot.median, delta=10) 34 | 35 | def test_timer_decorator_return_value(self): 36 | expected_return_value = 'timer' 37 | 38 | @self.timer 39 | def _test_decorator_return_value(): 40 | return expected_return_value 41 | 42 | self.assertEqual(expected_return_value, _test_decorator_return_value()) 43 | 44 | def test_timer_context_manager(self): 45 | for i in range(3): 46 | with self.timer: 47 | time.sleep(0.035) 48 | 49 | self.assertAlmostEqual(35, self.timer.mean, delta=10) 50 | self.assertAlmostEqual(35, self.timer.snapshot.median, delta=10) 51 | 52 | 53 | class UtilizationTimerTest(TestCase): 54 | def setUp(self): 55 | self.timer = UtilizationTimer() 56 | 57 | def tearDown(self): 58 | self.timer.stop() 59 | 60 | def test_timer(self): 61 | for i in range(5): 62 | self.timer.update(100) 63 | self.timer.update(150) 64 | self.timer.meter.tick() 65 | self.timer.duration_meter.tick() 66 | 67 | self.assertAlmostEqual(250, self.timer.one_minute_utilization, 68 | delta=10) 69 | -------------------------------------------------------------------------------- /tests/reporter/test_ganglia.py: -------------------------------------------------------------------------------- 1 | from ganglia import GMetric 2 | 3 | try: 4 | from mock import patch 5 | except ImportError: 6 | from unittest.mock import patch # noqa 7 | 8 | from unittest import TestCase 9 | 10 | from metrology import Metrology 11 | from metrology.reporter.ganglia import GangliaReporter 12 | 13 | 14 | class GangliaReporterTest(TestCase): 15 | def setUp(self): 16 | self.reporter = GangliaReporter("Group Name", "localhost", 8649) 17 | 18 | Metrology.meter('meter').mark() 19 | Metrology.counter('counter').increment() 20 | Metrology.timer('timer').update(5) 21 | Metrology.utilization_timer('utimer').update(5) 22 | 23 | def tearDown(self): 24 | self.reporter.stop() 25 | Metrology.stop() 26 | 27 | @patch.object(GMetric, "send") 28 | def test_write(self, mock): 29 | self.reporter.write() 30 | self.assertTrue(mock.called) 31 | -------------------------------------------------------------------------------- /tests/reporter/test_graphite.py: -------------------------------------------------------------------------------- 1 | try: 2 | from StringIO import StringIO 3 | from mock import patch 4 | except ImportError: 5 | from io import StringIO # noqa 6 | from unittest.mock import patch # noqa 7 | 8 | from unittest import TestCase 9 | 10 | from metrology import Metrology 11 | from metrology.reporter.graphite import GraphiteReporter 12 | 13 | 14 | class GraphiteReporterTest(TestCase): 15 | def tearDown(self): 16 | Metrology.stop() 17 | 18 | @patch.object(GraphiteReporter, 'socket') 19 | def test_send_nobatch(self, mock): 20 | self.reporter = GraphiteReporter('localhost', 3333, batch_size=1) 21 | 22 | Metrology.meter('meter').mark() 23 | Metrology.counter('counter').increment() 24 | Metrology.timer('timer').update(5) 25 | Metrology.utilization_timer('utimer').update(5) 26 | Metrology.histogram('histogram').update(5) 27 | self.reporter.write() 28 | self.assertTrue(mock.sendall.called) 29 | self.assertEqual(60, len(mock.sendall.call_args_list)) 30 | self.reporter.stop() 31 | 32 | @patch.object(GraphiteReporter, 'socket') 33 | def test_send_batch(self, mock): 34 | self.reporter = GraphiteReporter('localhost', 3333, batch_size=2) 35 | 36 | Metrology.meter('meter').mark() 37 | Metrology.counter('counter').increment() 38 | Metrology.timer('timer').update(5) 39 | Metrology.utilization_timer('utimer').update(5) 40 | Metrology.histogram('histogram').update(5) 41 | self.reporter.write() 42 | self.assertTrue(mock.sendall.called) 43 | self.assertEqual(30, len(mock.sendall.call_args_list)) 44 | self.reporter.stop() 45 | 46 | @patch.object(GraphiteReporter, 'socket') 47 | def test_metric_w_tags(self, mock): 48 | self.reporter = GraphiteReporter('localhost', 3333, batch_size=1) 49 | 50 | Metrology.meter({ 51 | "name": "meter", 52 | "type": "A", 53 | "category": "B" 54 | }).mark() 55 | 56 | self.reporter.write() 57 | self.assertTrue(mock.sendall.called) 58 | sent_text = ''.join(call[0][0].decode("ascii") 59 | for call in mock.sendall.call_args_list) 60 | self.assertIn("meter.count;", sent_text) 61 | self.assertIn(";type=A", sent_text) 62 | self.assertIn(";category=B", sent_text) 63 | self.reporter.stop() 64 | 65 | @patch.object(GraphiteReporter, 'socket') 66 | def test_sanitize_metric(self, mock): 67 | self.reporter = GraphiteReporter('localhost', 3333, batch_size=1) 68 | 69 | Metrology.meter('test.{met"er)|').mark() 70 | 71 | self.reporter.write() 72 | self.assertTrue(mock.sendall.called) 73 | sent_text = ''.join(call[0][0].decode("ascii") 74 | for call in mock.sendall.call_args_list) 75 | self.assertIn("test._met_er_", sent_text) 76 | self.reporter.stop() 77 | 78 | @patch.object(GraphiteReporter, 'socket') 79 | def test_sanitize_metric_w_tags(self, mock): 80 | self.reporter = GraphiteReporter('localhost', 3333, batch_size=1) 81 | 82 | Metrology.meter({ 83 | "name": "meter", 84 | "typ;e=": "~A" 85 | }).mark() 86 | 87 | Metrology.meter({ 88 | "name": "meter2", 89 | "": "" 90 | }).mark() 91 | 92 | self.reporter.write() 93 | self.assertTrue(mock.sendall.called) 94 | sent_text = ''.join(call[0][0].decode("ascii") 95 | for call in mock.sendall.call_args_list) 96 | self.assertIn("typ_e_=_A", sent_text) 97 | self.assertIn("empty_tag=empty_value", sent_text) 98 | self.reporter.stop() 99 | -------------------------------------------------------------------------------- /tests/reporter/test_graphite_pickle.py: -------------------------------------------------------------------------------- 1 | try: 2 | from StringIO import StringIO 3 | from mock import patch 4 | except ImportError: 5 | from io import StringIO # noqa 6 | from unittest.mock import patch # noqa 7 | 8 | from unittest import TestCase 9 | 10 | from metrology import Metrology 11 | from metrology.reporter.graphite import GraphiteReporter 12 | 13 | 14 | class GraphiteReporterTest(TestCase): 15 | def tearDown(self): 16 | Metrology.stop() 17 | 18 | @patch.object(GraphiteReporter, 'socket') 19 | def test_send_nobatch(self, mock): 20 | self.reporter = GraphiteReporter('localhost', 3334, 21 | pickle=True, batch_size=1) 22 | 23 | Metrology.meter('meter').mark() 24 | Metrology.counter('counter').increment() 25 | Metrology.timer('timer').update(5) 26 | Metrology.utilization_timer('utimer').update(5) 27 | Metrology.histogram('histogram').update(5) 28 | self.reporter.write() 29 | self.assertTrue(mock.sendall.called) 30 | self.assertEqual(60, len(mock.sendall.call_args_list)) 31 | self.reporter.stop() 32 | 33 | @patch.object(GraphiteReporter, 'socket') 34 | def test_send_batch(self, mock): 35 | self.reporter = GraphiteReporter('localhost', 3334, 36 | pickle=True, batch_size=2) 37 | 38 | Metrology.meter('meter').mark() 39 | Metrology.counter('counter').increment() 40 | Metrology.timer('timer').update(5) 41 | Metrology.utilization_timer('utimer').update(5) 42 | Metrology.histogram('histogram').update(5) 43 | self.reporter.write() 44 | self.assertTrue(mock.sendall.called) 45 | self.assertEqual(30, len(mock.sendall.call_args_list)) 46 | self.reporter.stop() 47 | -------------------------------------------------------------------------------- /tests/reporter/test_librato.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | try: 4 | from mock import patch 5 | except ImportError: 6 | from unittest.mock import patch # noqa 7 | 8 | from unittest import TestCase 9 | 10 | from metrology import Metrology 11 | from metrology.reporter.librato import LibratoReporter 12 | 13 | 14 | class LibratoReporterTest(TestCase): 15 | def setUp(self): 16 | self.reporter = LibratoReporter("", "") 17 | 18 | Metrology.meter('meter').mark() 19 | Metrology.counter('counter').increment() 20 | Metrology.timer('timer').update(5) 21 | Metrology.utilization_timer({ 22 | 'name': 'utimer', 23 | 'type': 'A' 24 | }).update(5) 25 | 26 | def tearDown(self): 27 | self.reporter.stop() 28 | Metrology.stop() 29 | 30 | @patch.object(requests, "post") 31 | def test_write(self, mock): 32 | self.reporter.write() 33 | self.assertTrue(mock.called) 34 | self.assertTrue("gauges" in mock.call_args_list[0][1]['data']) 35 | self.assertTrue("counters" in mock.call_args_list[0][1]['data']) 36 | self.assertTrue("tags" in mock.call_args_list[0][1]['data']) 37 | self.assertTrue("type" in mock.call_args_list[0][1]['data']) 38 | -------------------------------------------------------------------------------- /tests/reporter/test_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | try: 4 | from StringIO import StringIO 5 | except ImportError: 6 | from io import StringIO # noqa 7 | 8 | from unittest import TestCase 9 | 10 | from metrology import Metrology 11 | from metrology.reporter.logger import LoggerReporter 12 | 13 | 14 | class LoggerReporterTest(TestCase): 15 | def setUp(self): 16 | self.output = StringIO() 17 | logging.basicConfig(stream=self.output, level=logging.INFO) 18 | 19 | self.reporter = LoggerReporter() 20 | 21 | Metrology.meter('meter').mark() 22 | Metrology.counter('counter').increment() 23 | Metrology.timer('timer').update(5) 24 | Metrology.utilization_timer('utimer').update(5) 25 | 26 | Metrology.meter({ 27 | 'name': 'meter', 28 | 'hostname': 'testhost.test' 29 | }).mark() 30 | Metrology.counter({ 31 | 'name': 'counter', 32 | 'hostname': 'testhost.test' 33 | }).increment() 34 | Metrology.timer({ 35 | 'name': 'timer', 36 | 'hostname': 'testhost.test' 37 | }).update(5) 38 | Metrology.utilization_timer({ 39 | 'name': 'utimer', 40 | 'hostname': 'testhost.test' 41 | }).update(5) 42 | 43 | def tearDown(self): 44 | self.reporter.stop() 45 | Metrology.stop() 46 | 47 | def test_write(self): 48 | self.reporter.write() 49 | output = self.output.getvalue() 50 | self.assertTrue('median=' in output) 51 | self.assertTrue('testhost.test' in output) 52 | -------------------------------------------------------------------------------- /tests/reporter/test_statsd.py: -------------------------------------------------------------------------------- 1 | try: 2 | from StringIO import StringIO 3 | from mock import patch 4 | except ImportError: 5 | from io import StringIO # noqa 6 | from unittest.mock import patch # noqa 7 | from unittest import TestCase 8 | 9 | from metrology import Metrology 10 | from metrology.reporter.statsd import StatsDReporter 11 | 12 | 13 | class StatsDReporterTest(TestCase): 14 | def tearDown(self): 15 | Metrology.stop() 16 | 17 | @patch.object(StatsDReporter, 'socket') 18 | def test_send_nobatch(self, mock): 19 | self.reporter = StatsDReporter('localhost', 3333, 20 | batch_size=1, conn_type='tcp') 21 | 22 | Metrology.meter('meter').mark() 23 | Metrology.counter('counter').increment() 24 | Metrology.timer('timer').update(5) 25 | Metrology.utilization_timer('utimer').update(5) 26 | Metrology.histogram('histogram').update(5) 27 | self.reporter.write() 28 | self.assertTrue(mock.sendall.called) 29 | self.assertEqual(37, len(mock.sendall.call_args_list)) 30 | self.reporter.stop() 31 | 32 | @patch.object(StatsDReporter, 'socket') 33 | def test_send_batch(self, mock): 34 | self.reporter = StatsDReporter('localhost', 3333, 35 | batch_size=2, conn_type='tcp') 36 | 37 | Metrology.meter('meter').mark() 38 | Metrology.counter('counter').increment() 39 | Metrology.timer('timer').update(5) 40 | Metrology.utilization_timer('utimer').update(5) 41 | Metrology.histogram('histogram').update(5) 42 | self.reporter.write() 43 | self.assertTrue(mock.sendall.called) 44 | self.assertEqual(19, len(mock.sendall.call_args_list)) 45 | self.reporter.stop() 46 | 47 | @patch.object(StatsDReporter, 'socket') 48 | def test_udp_send_nobatch(self, mock): 49 | self.reporter = StatsDReporter('localhost', 3333, 50 | batch_size=1, conn_type='udp') 51 | 52 | Metrology.meter('meter').mark() 53 | Metrology.counter('counter').increment() 54 | Metrology.timer('timer').update(5) 55 | Metrology.utilization_timer('utimer').update(5) 56 | Metrology.histogram('histogram').update(5) 57 | self.reporter.write() 58 | self.assertTrue(mock.sendto.called) 59 | self.assertEqual(37, len(mock.sendto.call_args_list)) 60 | self.reporter.stop() 61 | 62 | @patch.object(StatsDReporter, 'socket') 63 | def test_udp_send_batch(self, mock): 64 | self.reporter = StatsDReporter('localhost', 3333, 65 | batch_size=2, conn_type='udp') 66 | 67 | Metrology.meter('meter').mark() 68 | Metrology.counter('counter').increment() 69 | Metrology.timer('timer').update(5) 70 | Metrology.utilization_timer('utimer').update(5) 71 | Metrology.histogram('histogram').update(5) 72 | self.reporter.write() 73 | self.assertTrue(mock.sendto.called) 74 | self.assertEqual(19, len(mock.sendto.call_args_list)) 75 | self.reporter.stop() 76 | 77 | @patch.object(StatsDReporter, 'socket') 78 | def test_metric_w_tags(self, mock): 79 | self.reporter = StatsDReporter('localhost', 3333, 80 | batch_size=1, conn_type='tcp') 81 | 82 | Metrology.meter({ 83 | 'name': 'meter', 84 | 'type': 'A', 85 | 'category': 'B' 86 | }).mark() 87 | self.reporter.write() 88 | self.assertTrue(mock.sendall.called) 89 | sent_text = ''.join(call[0][0].decode("ascii") 90 | for call in mock.sendall.call_args_list) 91 | self.assertIn('meter,', sent_text) 92 | self.assertIn(',type=A', sent_text) 93 | self.assertIn(',category=B', sent_text) 94 | self.reporter.stop() 95 | -------------------------------------------------------------------------------- /tests/stats/test_ewma.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from metrology.stats.ewma import EWMA 4 | 5 | 6 | class EwmaTest(TestCase): 7 | def _one_minute(self, ewma): 8 | for i in range(12): 9 | ewma.tick() 10 | 11 | def test_one_minute_ewma(self): 12 | ewma = EWMA.m1() 13 | ewma.update(3) 14 | ewma.tick() 15 | self.assertAlmostEqual(ewma.rate, 0.6, places=6) 16 | 17 | self._one_minute(ewma) 18 | self.assertAlmostEqual(ewma.rate, 0.22072766, places=6) 19 | 20 | self._one_minute(ewma) 21 | self.assertAlmostEqual(ewma.rate, 0.08120117, places=6) 22 | 23 | def test_five_minute_ewma(self): 24 | ewma = EWMA.m5() 25 | ewma.update(3) 26 | ewma.tick() 27 | self.assertAlmostEqual(ewma.rate, 0.6, places=6) 28 | 29 | self._one_minute(ewma) 30 | self.assertAlmostEqual(ewma.rate, 0.49123845, places=6) 31 | 32 | self._one_minute(ewma) 33 | self.assertAlmostEqual(ewma.rate, 0.40219203, places=6) 34 | 35 | def test_fifteen_minute_ewma(self): 36 | ewma = EWMA.m15() 37 | ewma.update(3) 38 | ewma.tick() 39 | self.assertAlmostEqual(ewma.rate, 0.6, places=6) 40 | 41 | self._one_minute(ewma) 42 | self.assertAlmostEqual(ewma.rate, 0.56130419, places=6) 43 | 44 | self._one_minute(ewma) 45 | self.assertAlmostEqual(ewma.rate, 0.52510399, places=6) 46 | 47 | def test_clear_ewma(self): 48 | ewma = EWMA.m15() 49 | ewma.update(3) 50 | ewma.tick() 51 | ewma.clear() 52 | self.assertAlmostEqual(ewma.rate, 0) 53 | -------------------------------------------------------------------------------- /tests/stats/test_sample.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from unittest import TestCase 4 | 5 | try: 6 | from mock import patch 7 | except ImportError: 8 | from unittest.mock import patch # noqa 9 | 10 | from metrology.stats.sample import UniformSample, ExponentiallyDecayingSample 11 | 12 | 13 | class UniformSampleTest(TestCase): 14 | def test_sample(self): 15 | sample = UniformSample(100) 16 | for i in range(1000): 17 | sample.update(i) 18 | snapshot = sample.snapshot() 19 | self.assertEqual(sample.size(), 100) 20 | self.assertEqual(snapshot.size(), 100) 21 | 22 | for value in snapshot.values: 23 | self.assertTrue(value < 1000.0) 24 | self.assertTrue(value >= 0.0) 25 | 26 | 27 | class ExponentiallyDecayingSampleTest(TestCase): 28 | def test_sample_1000(self): 29 | sample = ExponentiallyDecayingSample(100, 0.99) 30 | for i in range(1000): 31 | sample.update(i) 32 | self.assertEqual(sample.size(), 100) 33 | snapshot = sample.snapshot() 34 | self.assertEqual(snapshot.size(), 100) 35 | 36 | for value in snapshot.values: 37 | self.assertTrue(value < 1000.0) 38 | self.assertTrue(value >= 0.0) 39 | 40 | def test_sample_10(self): 41 | sample = ExponentiallyDecayingSample(100, 0.99) 42 | for i in range(10): 43 | sample.update(i) 44 | self.assertEqual(sample.size(), 10) 45 | 46 | snapshot = sample.snapshot() 47 | self.assertEqual(snapshot.size(), 10) 48 | 49 | for value in snapshot.values: 50 | self.assertTrue(value < 10.0) 51 | self.assertTrue(value >= 0.0) 52 | 53 | def test_sample_100(self): 54 | sample = ExponentiallyDecayingSample(1000, 0.01) 55 | for i in range(100): 56 | sample.update(i) 57 | self.assertEqual(sample.size(), 100) 58 | 59 | snapshot = sample.snapshot() 60 | self.assertEqual(snapshot.size(), 100) 61 | 62 | for value in snapshot.values: 63 | self.assertTrue(value < 100.0) 64 | self.assertTrue(value >= 0.0) 65 | 66 | def timestamp_to_priority_is_noop(f): 67 | """ 68 | Decorator that patches ExponentiallyDecayingSample class such that the 69 | timestamp->priority function is a no-op. 70 | """ 71 | weight_fn = "metrology.stats.sample.ExponentiallyDecayingSample.weight" 72 | return patch(weight_fn, lambda self, x: x)(patch("random.random", 73 | lambda: 1.0)(f)) 74 | 75 | @timestamp_to_priority_is_noop 76 | def test_sample_eviction(self): 77 | kSampleSize = 10 78 | kDefaultValue = 1.0 79 | sample = ExponentiallyDecayingSample(kSampleSize, 0.01) 80 | 81 | timestamps = range(1, kSampleSize * 2) 82 | for count, timestamp in enumerate(timestamps): 83 | sample.update(kDefaultValue, timestamp) 84 | self.assertLessEqual(len(sample.values), kSampleSize) 85 | self.assertLessEqual(len(sample.values), count + 1) 86 | expected_min_key = timestamps[max(0, count + 1 - kSampleSize)] 87 | self.assertEqual(min(sample.values)[0], expected_min_key) 88 | 89 | @timestamp_to_priority_is_noop 90 | def test_sample_ordering(self): 91 | kSampleSize = 3 92 | sample = ExponentiallyDecayingSample(kSampleSize, 0.01) 93 | 94 | timestamps = range(1, kSampleSize + 1) 95 | values = ["value_{0}".format(i) for i in timestamps] 96 | expected = list(zip(timestamps, values)) 97 | for timestamp, value in expected: 98 | sample.update(value, timestamp) 99 | self.assertListEqual(sorted(sample.values), expected) 100 | 101 | # timestamp less than any existing => no-op 102 | sample.update("ignore", 0.5) 103 | self.assertEqual(sorted(sample.values), expected) 104 | 105 | # out of order insertions 106 | expected = [3.0, 4.0, 5.0] 107 | sample.update("ignore", 5.0) 108 | sample.update("ignore", 4.0) 109 | self.assertListEqual(sorted(k for k, _ in sample.values), expected) 110 | 111 | # collision 112 | marker = "marker" 113 | replacement_timestamp = 5.0 114 | expected = [4.0, 5.0, 5.0] 115 | sample.update(marker, replacement_timestamp) 116 | self.assertListEqual(sorted(k for k, _ in sample.values), expected) 117 | 118 | replacement_timestamp = 4.0 119 | expected = [4.0, 5.0, 5.0] 120 | sample.update(marker, replacement_timestamp) 121 | self.assertListEqual(sorted(k for k, _ in sample.values), expected) 122 | 123 | def test_rescale_threshold(self): 124 | infinity = float('inf') 125 | for alpha in (0.015, 1e-10, 1): 126 | rescale_threshold = \ 127 | ExponentiallyDecayingSample.calculate_rescale_threshold(alpha) 128 | min_rand_val = 1.0 / (2 ** 32) 129 | max_priority = math.exp(alpha * rescale_threshold) / min_rand_val 130 | self.assertLess(max_priority, infinity) 131 | -------------------------------------------------------------------------------- /tests/stats/test_snapshot.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from metrology.stats.snapshot import Snapshot 4 | 5 | 6 | class SnapshotTest(TestCase): 7 | def setUp(self): 8 | self.snapshot = Snapshot([5, 1, 2, 3, 4]) 9 | 10 | def test_median(self): 11 | self.assertAlmostEqual(self.snapshot.median, 3, 1) 12 | 13 | def test_75th_percentile(self): 14 | self.assertAlmostEqual(self.snapshot.percentile_75th, 4.5, 1) 15 | 16 | def test_95th_percentile(self): 17 | self.assertAlmostEqual(self.snapshot.percentile_95th, 5.0, 1) 18 | 19 | def test_98th_percentile(self): 20 | self.assertAlmostEqual(self.snapshot.percentile_98th, 5.0, 1) 21 | 22 | def test_99th_percentile(self): 23 | self.assertAlmostEqual(self.snapshot.percentile_99th, 5.0, 1) 24 | 25 | def test_999th_percentile(self): 26 | self.assertAlmostEqual(self.snapshot.percentile_999th, 5.0, 1) 27 | 28 | def test_size(self): 29 | self.assertEqual(self.snapshot.size(), 5) 30 | -------------------------------------------------------------------------------- /tests/test_metrology.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from metrology import Metrology 4 | from metrology.instruments.gauge import Gauge 5 | from metrology.instruments.healthcheck import HealthCheck 6 | from metrology.registry import registry 7 | 8 | 9 | class MetrologyTest(TestCase): 10 | def setUp(self): 11 | registry.clear() 12 | 13 | def tearDown(self): 14 | registry.clear() 15 | 16 | def test_get(self): 17 | Metrology.counter('test') 18 | self.assertTrue(Metrology.get('test') is not None) 19 | 20 | def test_counter(self): 21 | self.assertTrue(Metrology.counter('test') is not None) 22 | 23 | def test_meter(self): 24 | self.assertTrue(Metrology.meter('test') is not None) 25 | 26 | def test_gauge(self): 27 | self.assertTrue(Metrology.gauge('test', Gauge) is not None) 28 | 29 | def test_timer(self): 30 | self.assertTrue(Metrology.timer('test') is not None) 31 | 32 | def test_utilization_timer(self): 33 | self.assertTrue(Metrology.utilization_timer('test') is not None) 34 | 35 | def test_histogram(self): 36 | self.assertTrue(Metrology.histogram('test') is not None) 37 | 38 | def test_health_check(self): 39 | health = Metrology.health_check('test', HealthCheck) 40 | self.assertTrue(health is not None) 41 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from metrology.registry import Registry 4 | from metrology.instruments.gauge import Gauge 5 | from metrology.instruments.healthcheck import HealthCheck 6 | from metrology.exceptions import RegistryException 7 | 8 | 9 | class DummyGauge(Gauge): 10 | def value(self): 11 | return "wow" 12 | 13 | 14 | class DummyHealthCheck(HealthCheck): 15 | def check(self): 16 | return True 17 | 18 | 19 | class RegistryTest(TestCase): 20 | def setUp(self): 21 | self.registry = Registry() 22 | 23 | def tearDown(self): 24 | self.registry.stop() 25 | 26 | def test_counter(self): 27 | self.assertTrue(self.registry.counter('test') is not None) 28 | 29 | def test_meter(self): 30 | self.assertTrue(self.registry.meter('test') is not None) 31 | 32 | def test_gauge(self): 33 | self.assertTrue(self.registry.gauge('test', DummyGauge()) is not None) 34 | 35 | def test_timer(self): 36 | self.assertTrue(self.registry.timer('test') is not None) 37 | 38 | def test_utilization_timer(self): 39 | self.assertTrue(self.registry.utilization_timer('test') is not None) 40 | 41 | def test_histogram(self): 42 | self.assertTrue(self.registry.histogram('test') is not None) 43 | 44 | def test_health_check(self): 45 | health = self.registry.health_check('test', DummyHealthCheck()) 46 | self.assertTrue(health is not None) 47 | 48 | def test_identity(self): 49 | a = self.registry.counter("test") 50 | b = self.registry.counter("test") 51 | self.assertEqual(id(a), id(b)) 52 | 53 | def test_separation(self): 54 | a = self.registry.counter("test") 55 | b = self.registry.counter("test2") 56 | self.assertNotEqual(id(a), id(b)) 57 | 58 | def test_type_identity(self): 59 | self.registry.counter("test") 60 | with self.assertRaises(RegistryException): 61 | self.registry.histogram("test") 62 | 63 | def test_identity_w_tags(self): 64 | a = self.registry.counter({ 65 | "name": "test", 66 | "type": "A" 67 | }) 68 | b = self.registry.counter({ 69 | "name": "test", 70 | "type": "A" 71 | }) 72 | self.assertEqual(id(a), id(b)) 73 | 74 | def test_separation_w_tags(self): 75 | a = self.registry.counter({ 76 | "name": "test", 77 | "type": "A" 78 | }) 79 | b = self.registry.counter({ 80 | "name": "test", 81 | "type": "B" 82 | }) 83 | self.assertNotEqual(id(a), id(b)) 84 | 85 | def test_type_identity_w_tags(self): 86 | self.registry.counter({ 87 | "name": "test", 88 | "type": "A" 89 | }) 90 | with self.assertRaises(RegistryException): 91 | self.registry.histogram({ 92 | "name": "test", 93 | "type": "A" 94 | }) 95 | -------------------------------------------------------------------------------- /tests/test_wsgi.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import threading 3 | 4 | from unittest import TestCase 5 | from wsgiref.simple_server import demo_app, make_server, WSGIRequestHandler 6 | 7 | from metrology import Metrology 8 | from metrology.wsgi import Middleware 9 | 10 | 11 | class SilentWSGIHandler(WSGIRequestHandler): 12 | def log_message(*args): 13 | pass 14 | 15 | 16 | class TestServer(object): 17 | def __init__(self, application): 18 | self.application = application 19 | self.server = make_server( 20 | '127.0.0.1', 0, application, handler_class=SilentWSGIHandler) 21 | 22 | def get(self, *args, **kwargs): 23 | return self.request('get', *args, **kwargs) 24 | 25 | def request(self, method, path, *args, **kwargs): 26 | url = 'http://{0[0]}:{0[1]}{1}'.format(self.server.server_address, 27 | path) 28 | thread = threading.Thread(target=self.server.handle_request) 29 | thread.start() 30 | response = requests.request(method, url, *args, **kwargs) 31 | thread.join() 32 | return response 33 | 34 | 35 | class MiddlewareTest(TestCase): 36 | @classmethod 37 | def setUpClass(cls): 38 | # Initialize test application 39 | cls.application = Middleware(demo_app) 40 | cls.server = TestServer(cls.application) 41 | super(MiddlewareTest, cls).setUpClass() 42 | 43 | def tearDown(self): 44 | Metrology.stop() 45 | 46 | def test_request(self): 47 | self.server.get('/') 48 | self.assertEqual(1, Metrology.meter('request').count) 49 | self.assertEqual(1, Metrology.timer('request_time').count) 50 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py27,py38,py37,pep8,docs 4 | 5 | [testenv] 6 | deps = 7 | -r{toxinidir}/requirements.txt 8 | -r{toxinidir}/test-requirements.txt 9 | setenv = 10 | LC_ALL={env:LC_ALL:en_US.UTF-8} 11 | commands = 12 | py.test -p no:logging 13 | 14 | [testenv:pep8] 15 | commands = flake8 16 | exclude = .venv,.git,.tox,dist,docs,*lib/python*,*egg,build 17 | 18 | [testenv:docs] 19 | changedir = docs 20 | deps = 21 | sphinx 22 | ganglia 23 | commands = 24 | {envbindir}/sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 25 | --------------------------------------------------------------------------------