pax_global_header00006660000000000000000000000064144242646650014527gustar00rootroot0000000000000052 comment=e4611129c5dd9716e1ff420b1ae81f9ed2df27f2 django-prometheus-2.3.1/000077500000000000000000000000001442426466500151655ustar00rootroot00000000000000django-prometheus-2.3.1/.github/000077500000000000000000000000001442426466500165255ustar00rootroot00000000000000django-prometheus-2.3.1/.github/workflows/000077500000000000000000000000001442426466500205625ustar00rootroot00000000000000django-prometheus-2.3.1/.github/workflows/ci.yml000066400000000000000000000041621442426466500217030ustar00rootroot00000000000000name: CI on: push: branches: - "*" pull_request: branches: - master concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: true jobs: test: timeout-minutes: 30 strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-22.04] runs-on: ${{ matrix.os }} name: "${{ matrix.os }} Python: ${{ matrix.python-version }}" services: redis: image: redis:6.2.6 ports: - 6379:6379 memcached: image: memcached:1.6.12 ports: - 11211:11211 mysql: image: mysql:8.0.27 env: MYSQL_ALLOW_EMPTY_PASSWORD: yes ports: - 3306:3306 postgresql: image: postgis/postgis:14-master env: POSTGRES_HOST_AUTH_METHOD: trust ports: - 5432:5432 steps: - name: Install OS Packages run: | sudo apt-get update sudo apt-get install binutils libproj-dev gdal-bin libmemcached-dev - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install -U "pip>=23.1.1" pip install -U "tox-gh-actions==3.1.0" coverage - name: Log versions run: | python --version pip --version psql -V mysql -V - name: prep DB env: MYSQL_TCP_PORT: 3306 MYSQL_HOST: localhost PGHOST: localhost PGPORT: 5432 run: | psql -U postgres -c 'CREATE DATABASE postgis' psql -U postgres postgis -c 'CREATE EXTENSION IF NOT EXISTS postgis;' mysql --protocol=TCP --user=root -e 'create database django_prometheus_1;' - name: Run test and linters via Tox run: tox - name: Process code coverage run: | coverage combine .coverage django_prometheus/tests/end2end/.coverage coverage xml django-prometheus-2.3.1/.github/workflows/pre-release.yml000066400000000000000000000015001442426466500235050ustar00rootroot00000000000000name: Release on: push: branches: - "master" jobs: pre-release-django-prometheus-job: runs-on: ubuntu-latest name: pre-release django-prometheus permissions: id-token: write steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python 3.9 uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip pip install wheel setuptools packaging twine build --upgrade - name: Set version number run: python update_version_from_git.py - name: Build run: python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@v1.8.5 with: skip-existing: true verbose: true print-hash: true django-prometheus-2.3.1/.github/workflows/release.yml000066400000000000000000000034111442426466500227240ustar00rootroot00000000000000name: Release on: push: tags: - v[0-9]+.[0-9]+.[0-9]+ jobs: org-check: name: Check GitHub Organization if: ${{ github.repository_owner == 'korfuri' }} runs-on: ubuntu-latest steps: - name: Noop run: "true" determine-tag: name: Determine the release tag to operate against. needs: org-check runs-on: ubuntu-latest outputs: release-tag: ${{ steps.determine-tag.outputs.release-tag }} release-version: ${{ steps.determine-tag.outputs.release-version }} steps: - name: Determine Tag id: determine-tag run: | RELEASE_TAG=${GITHUB_REF#refs/tags/} echo "Release tag: ${RELEASE_TAG}" if [[ "${RELEASE_TAG}" =~ ^v[0-9]+.[0-9]+.[0-9]+$ ]]; then echo "release-tag=${RELEASE_TAG}" >> $GITHUB_OUTPUT echo "release-version=${RELEASE_TAG#v}" >> $GITHUB_OUTPUT else echo "::error::Release tag '${RELEASE_TAG}' must match 'v\d+.\d+.\d+'." exit 1 fi release-django-prometheus-job: runs-on: ubuntu-latest name: Release Django-Promethues needs: determine-tag permissions: id-token: write steps: - uses: actions/checkout@v3 with: ref: ${{ needs.determine-tag.outputs.release-tag }} fetch-depth: 0 - name: Set up Python 3.9 uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip pip install wheel setuptools packaging twine build --upgrade - name: Build run: python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@v1.8.5 with: skip-existing: true verbose: true print-hash: true django-prometheus-2.3.1/.gitignore000066400000000000000000000021461442426466500171600ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env*/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover # Translations *.mo *.pot # Django stuff: *.log *.sqlite3 # Sphinx documentation docs/_build/ # PyBuilder target/ # VSCode .vscode/ ### Emacs ### # -*- mode: gitignore; -*- *~ \#*\# /.emacs.desktop /.emacs.desktop.lock *.elc auto-save-list tramp .\#* # Org-mode .org-id-locations *_archive # flymake-mode *_flymake.* # eshell files /eshell/history /eshell/lastdir # elpa packages /elpa/ # reftex files *.rel # AUCTeX auto folder /auto/ # cask packages .cask/ # venv venv/ ### Prometheus ### examples/prometheus/data django-prometheus-2.3.1/CHANGELOG.md000066400000000000000000000032361442426466500170020ustar00rootroot00000000000000# Changelog ## v2.3.1 - May 2nd, 2023 * Fix postgresql provider import, Thaks [@wilsonehusin](https://github.com/korfuri/django-prometheus/pull/402) ## v2.3.0 - May 2nd, 2023 * Remove support for Python 3.6, Django versions older tha than 3.2 * Fix two latency metrics not using PROMETHEUS_LATENCY_BUCKETS setting, Thanks [@AleksaC](https://github.com/korfuri/django-prometheus/pull/343) * Support new cache backend names in newer Django versions, Thanks [@tneuct](https://github.com/korfuri/django-prometheus/pull/329) * Make export of migrations False by default, Thanks [@kaypee90](https://github.com/korfuri/django-prometheus/pull/313) * Add support for Django 4.1, Python 3.11 * Add support for Django 4.2 and Psycopg 3 ## v2.2.0 - December 19, 2021 * Switch to Github Actions CI, remove travis-ci. * Add support for Django 3.2 & 4.0 and Python 3.9 & 3.10 ## v2.1.0 - August 22, 2020 * Remove support for older django and python versions * Add support for Django 3.0 and Django 3.1 * Add support for [PostGIS](https://github.com/korfuri/django-prometheus/pull/221), Thanks [@EverWinter23](https://github.com/EverWinter23) ## v2.0.0 - Jan 20, 2020 * Added support for newer Django and Python versions * Added an extensibility that applications to add their own labels to middleware (request/response) metrics * Allow overriding and setting custom bucket values for request/response latency histogram metric * Internal improvements: * use tox * Use pytest * use Black * Automate pre-releases on every commit ot master * Fix flaky tests. ## v1.1.0 - Sep 28, 2019 * maintenance release that updates this library to support recent and supported version of python & Djangodjango-prometheus-2.3.1/CONTRIBUTING.md000066400000000000000000000035631442426466500174250ustar00rootroot00000000000000# Contributing ## Git Feel free to send pull requests, even for the tiniest things. Watch for Travis' opinion on them ([![Build Status](https://travis-ci.org/korfuri/django-prometheus.svg?branch=master)](https://travis-ci.org/korfuri/django-prometheus)). Travis will also make sure your code is pep8 compliant, and it's a good idea to run flake8 as well (on django_prometheus/ and on tests/). The code contains "unused" imports on purpose so flake8 isn't run automatically. ## Tests Please write unit tests for your change. There are two kinds of tests: * Regular unit tests that test the code directly, without loading Django. This is limited to pieces of the code that don't depend on Django, since a lot of the Django code will require a full Django environment (anything that interacts with models, for instance, needs a full database configuration). * End-to-end tests are Django unit tests in a test application. The test application doubles as an easy way to interactively test your changes. It uses most of the basic Django features and a few advanced features, so you can test things for yourself. ### Running all tests ```shell python setup.py test cd tests/end2end/ && PYTHONPATH=../.. ./manage.py test ``` The former runs the regular unit tests, the latter runs the Django unit test. To avoid setting PYTHONPATH every time, you can also run `python setup.py install`. ### Running the test Django app ```shell cd tests/end2end/ && PYTHONPATH=../.. ./manage.py runserver ``` By default, this will start serving on http://localhost:8000/. Metrics are available at `/metrics`. ## Running Prometheus See for instructions on installing Prometheus. Once you have Prometheus installed, you can use the example rules and dashboard in `examples/prometheus/`. See `examples/prometheus/README.md` to run Prometheus and view the example dashboard. django-prometheus-2.3.1/LICENSE000066400000000000000000000261361442426466500162020ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. django-prometheus-2.3.1/MANIFEST.in000066400000000000000000000000421442426466500167170ustar00rootroot00000000000000include LICENSE include README.md django-prometheus-2.3.1/README.md000066400000000000000000000173301442426466500164500ustar00rootroot00000000000000# django-prometheus Export Django monitoring metrics for Prometheus.io [![Join the chat at https://gitter.im/django-prometheus/community](https://badges.gitter.im/django-prometheus/community.svg)](https://gitter.im/django-prometheus/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![PyPI version](https://badge.fury.io/py/django-prometheus.svg)](http://badge.fury.io/py/django-prometheus) [![Build Status](https://github.com/korfuri/django-prometheus/actions/workflows/ci.yml/badge.svg)](https://github.com/korfuri/django-prometheus/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/korfuri/django-prometheus/badge.svg?branch=master)](https://coveralls.io/github/korfuri/django-prometheus?branch=master) [![PyPi page link -- Python versions](https://img.shields.io/pypi/pyversions/django-prometheus.svg)](https://pypi.python.org/pypi/django-prometheus) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ## Features This library provides Prometheus metrics for Django related operations: * Requests & Responses * Database access done via [Django ORM](https://docs.djangoproject.com/en/3.2/topics/db/) * Cache access done via [Django Cache framework](https://docs.djangoproject.com/en/3.2/topics/cache/) ## Usage ### Requirements * Django >= 3.2 * Python 3.7 and above. ### Installation Install with: ```shell pip install django-prometheus ``` Or, if you're using a development version cloned from this repository: ```shell python path-to-where-you-cloned-django-prometheus/setup.py install ``` This will install [prometheus_client](https://github.com/prometheus/client_python) as a dependency. ### Quickstart In your settings.py: ```python INSTALLED_APPS = [ ... 'django_prometheus', ... ] MIDDLEWARE = [ 'django_prometheus.middleware.PrometheusBeforeMiddleware', # All your other middlewares go here, including the default # middlewares like SessionMiddleware, CommonMiddleware, # CsrfViewmiddleware, SecurityMiddleware, etc. 'django_prometheus.middleware.PrometheusAfterMiddleware', ] ``` In your urls.py: ```python urlpatterns = [ ... path('', include('django_prometheus.urls')), ] ``` ### Configuration Prometheus uses Histogram based grouping for monitoring latencies. The default buckets are: ```python PROMETHEUS_LATENCY_BUCKETS = (0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, 25.0, 50.0, 75.0, float("inf"),) ``` You can define custom buckets for latency, adding more buckets decreases performance but increases accuracy: ```python PROMETHEUS_LATENCY_BUCKETS = (.1, .2, .5, .6, .8, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.5, 9.0, 12.0, 15.0, 20.0, 30.0, float("inf")) ``` ### Monitoring your databases SQLite, MySQL, and PostgreSQL databases can be monitored. Just replace the `ENGINE` property of your database, replacing `django.db.backends` with `django_prometheus.db.backends`. ```python DATABASES = { 'default': { 'ENGINE': 'django_prometheus.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), }, } ``` ### Monitoring your caches Filebased, memcached, redis caches can be monitored. Just replace the cache backend to use the one provided by django_prometheus `django.core.cache.backends` with `django_prometheus.cache.backends`. ```python CACHES = { 'default': { 'BACKEND': 'django_prometheus.cache.backends.filebased.FileBasedCache', 'LOCATION': '/var/tmp/django_cache', } } ``` ### Monitoring your models You may want to monitor the creation/deletion/update rate for your model. This can be done by adding a mixin to them. This is safe to do on existing models (it does not require a migration). If your model is: ```python class Dog(models.Model): name = models.CharField(max_length=100, unique=True) breed = models.CharField(max_length=100, blank=True, null=True) age = models.PositiveIntegerField(blank=True, null=True) ``` Just add the `ExportModelOperationsMixin` as such: ```python from django_prometheus.models import ExportModelOperationsMixin class Dog(ExportModelOperationsMixin('dog'), models.Model): name = models.CharField(max_length=100, unique=True) breed = models.CharField(max_length=100, blank=True, null=True) age = models.PositiveIntegerField(blank=True, null=True) ``` This will export 3 metrics, `django_model_inserts_total{model="dog"}`, `django_model_updates_total{model="dog"}` and `django_model_deletes_total{model="dog"}`. Note that the exported metrics are counters of creations, modifications and deletions done in the current process. They are not gauges of the number of objects in the model. Starting with Django 1.7, migrations are also monitored. Two gauges are exported, `django_migrations_applied_by_connection` and `django_migrations_unapplied_by_connection`. You may want to alert if there are unapplied migrations. If you want to disable the Django migration metrics, set the `PROMETHEUS_EXPORT_MIGRATIONS` setting to False. ### Monitoring and aggregating the metrics Prometheus is quite easy to set up. An example prometheus.conf to scrape `127.0.0.1:8001` can be found in `examples/prometheus`. Here's an example of a PromDash displaying some of the metrics collected by django-prometheus: ![Example dashboard](https://raw.githubusercontent.com/korfuri/django-prometheus/master/examples/django-promdash.png) ## Adding your own metrics You can add application-level metrics in your code by using [prometheus_client](https://github.com/prometheus/client_python) directly. The exporter is global and will pick up your metrics. To add metrics to the Django internals, the easiest way is to extend django-prometheus' classes. Please consider contributing your metrics, pull requests are welcome. Make sure to read the Prometheus best practices on [instrumentation](http://prometheus.io/docs/practices/instrumentation/) and [naming](http://prometheus.io/docs/practices/naming/). ## Importing Django Prometheus using only local settings If you wish to use Django Prometheus but are not able to change the code base, it's possible to have all the default metrics by modifying only the settings. First step is to inject prometheus' middlewares and to add django_prometheus in INSTALLED_APPS ```python MIDDLEWARE = \ ['django_prometheus.middleware.PrometheusBeforeMiddleware'] + \ MIDDLEWARE + \ ['django_prometheus.middleware.PrometheusAfterMiddleware'] INSTALLED_APPS += ['django_prometheus'] ``` Second step is to create the /metrics end point, for that we need another file (called urls_prometheus_wrapper.py in this example) that will wraps the apps URLs and add one on top: ```python from django.urls import include, path urlpatterns = [] urlpatterns.append(path('prometheus/', include('django_prometheus.urls'))) urlpatterns.append(path('', include('myapp.urls'))) ``` This file will add a "/prometheus/metrics" end point to the URLs of django that will export the metrics (replace myapp by your project name). Then we inject the wrapper in settings: ```python ROOT_URLCONF = "graphite.urls_prometheus_wrapper" ``` ## Adding custom labels to middleware (request/response) metrics You can add application specific labels to metrics reported by the django-prometheus middleware. This involves extending the classes defined in middleware.py. * Extend the Metrics class and override the `register_metric` method to add the application specific labels. * Extend middleware classes, set the metrics_cls class attribute to the the extended metric class and override the label_metric method to attach custom metrics. See implementation example in [the test app](django_prometheus/tests/end2end/testapp/test_middleware_custom_labels.py#L19-L46) django-prometheus-2.3.1/django_prometheus/000077500000000000000000000000001442426466500207025ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/__init__.py000066400000000000000000000010221442426466500230060ustar00rootroot00000000000000"""Django-Prometheus https://github.com/korfuri/django-prometheus """ # Import all files that define metrics. This has the effect that # `import django_prometheus` will always instantiate all metric # objects right away. from django_prometheus import middleware, models __all__ = ["middleware", "models", "pip_prometheus"] __version__ = "2.3.1" # Import pip_prometheus to export the pip metrics automatically. try: import pip_prometheus except ImportError: # If people don't have pip, don't export anything. pass django-prometheus-2.3.1/django_prometheus/apps.py000066400000000000000000000016531442426466500222240ustar00rootroot00000000000000from django.apps import AppConfig from django.conf import settings import django_prometheus from django_prometheus.exports import SetupPrometheusExportsFromConfig from django_prometheus.migrations import ExportMigrations class DjangoPrometheusConfig(AppConfig): name = django_prometheus.__name__ verbose_name = "Django-Prometheus" def ready(self): """Initializes the Prometheus exports if they are enabled in the config. Note that this is called even for other management commands than `runserver`. As such, it is possible to scrape the metrics of a running `manage.py test` or of another command, which shouldn't be done for real monitoring (since these jobs are usually short-lived), but can be useful for debugging. """ SetupPrometheusExportsFromConfig() if getattr(settings, "PROMETHEUS_EXPORT_MIGRATIONS", False): ExportMigrations() django-prometheus-2.3.1/django_prometheus/cache/000077500000000000000000000000001442426466500217455ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/cache/__init__.py000066400000000000000000000000001442426466500240440ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/cache/backends/000077500000000000000000000000001442426466500235175ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/cache/backends/__init__.py000066400000000000000000000000001442426466500256160ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/cache/backends/django_memcached_consul.py000066400000000000000000000013641442426466500307100ustar00rootroot00000000000000from django_memcached_consul import memcached from django_prometheus.cache.metrics import ( django_cache_get_total, django_cache_hits_total, django_cache_misses_total, ) class MemcachedCache(memcached.MemcachedCache): """Inherit django_memcached_consul to add metrics about hit/miss ratio""" def get(self, key, default=None, version=None): django_cache_get_total.labels(backend="django_memcached_consul").inc() cached = super().get(key, default=None, version=version) if cached is not None: django_cache_hits_total.labels(backend="django_memcached_consul").inc() else: django_cache_misses_total.labels(backend="django_memcached_consul").inc() return cached or default django-prometheus-2.3.1/django_prometheus/cache/backends/filebased.py000066400000000000000000000013051442426466500260060ustar00rootroot00000000000000from django.core.cache.backends import filebased from django_prometheus.cache.metrics import ( django_cache_get_total, django_cache_hits_total, django_cache_misses_total, ) class FileBasedCache(filebased.FileBasedCache): """Inherit filebased cache to add metrics about hit/miss ratio""" def get(self, key, default=None, version=None): django_cache_get_total.labels(backend="filebased").inc() cached = super().get(key, default=None, version=version) if cached is not None: django_cache_hits_total.labels(backend="filebased").inc() else: django_cache_misses_total.labels(backend="filebased").inc() return cached or default django-prometheus-2.3.1/django_prometheus/cache/backends/locmem.py000066400000000000000000000012601442426466500253440ustar00rootroot00000000000000from django.core.cache.backends import locmem from django_prometheus.cache.metrics import ( django_cache_get_total, django_cache_hits_total, django_cache_misses_total, ) class LocMemCache(locmem.LocMemCache): """Inherit filebased cache to add metrics about hit/miss ratio""" def get(self, key, default=None, version=None): django_cache_get_total.labels(backend="locmem").inc() cached = super().get(key, default=None, version=version) if cached is not None: django_cache_hits_total.labels(backend="locmem").inc() else: django_cache_misses_total.labels(backend="locmem").inc() return cached or default django-prometheus-2.3.1/django_prometheus/cache/backends/memcached.py000066400000000000000000000016471442426466500260070ustar00rootroot00000000000000from django.core.cache.backends import memcached from django_prometheus.cache.metrics import ( django_cache_get_total, django_cache_hits_total, django_cache_misses_total, ) class MemcachedPrometheusCacheMixin: def get(self, key, default=None, version=None): django_cache_get_total.labels(backend="memcached").inc() cached = super().get(key, default=None, version=version) if cached is not None: django_cache_hits_total.labels(backend="memcached").inc() else: django_cache_misses_total.labels(backend="memcached").inc() return cached or default class PyLibMCCache(MemcachedPrometheusCacheMixin, memcached.PyLibMCCache): """Inherit memcached to add metrics about hit/miss ratio""" pass class PyMemcacheCache(MemcachedPrometheusCacheMixin, memcached.PyMemcacheCache): """Inherit memcached to add metrics about hit/miss ratio""" pass django-prometheus-2.3.1/django_prometheus/cache/backends/redis.py000066400000000000000000000022111442426466500251730ustar00rootroot00000000000000from django_redis import cache, exceptions from django_prometheus.cache.metrics import ( django_cache_get_fail_total, django_cache_get_total, django_cache_hits_total, django_cache_misses_total, ) class RedisCache(cache.RedisCache): """Inherit redis to add metrics about hit/miss/interruption ratio""" @cache.omit_exception def get(self, key, default=None, version=None, client=None): try: django_cache_get_total.labels(backend="redis").inc() cached = self.client.get(key, default=None, version=version, client=client) except exceptions.ConnectionInterrupted as e: django_cache_get_fail_total.labels(backend="redis").inc() if self._ignore_exceptions: if self._log_ignored_exceptions: cache.logger.error(str(e)) return default raise else: if cached is not None: django_cache_hits_total.labels(backend="redis").inc() return cached else: django_cache_misses_total.labels(backend="redis").inc() return default django-prometheus-2.3.1/django_prometheus/cache/metrics.py000066400000000000000000000012451442426466500237670ustar00rootroot00000000000000from prometheus_client import Counter from django_prometheus.conf import NAMESPACE django_cache_get_total = Counter( "django_cache_get_total", "Total get requests on cache", ["backend"], namespace=NAMESPACE, ) django_cache_hits_total = Counter( "django_cache_get_hits_total", "Total hits on cache", ["backend"], namespace=NAMESPACE, ) django_cache_misses_total = Counter( "django_cache_get_misses_total", "Total misses on cache", ["backend"], namespace=NAMESPACE, ) django_cache_get_fail_total = Counter( "django_cache_get_fail_total", "Total get request failures by cache", ["backend"], namespace=NAMESPACE, ) django-prometheus-2.3.1/django_prometheus/conf/000077500000000000000000000000001442426466500216275ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/conf/__init__.py000066400000000000000000000007231442426466500237420ustar00rootroot00000000000000from django.conf import settings NAMESPACE = "" PROMETHEUS_LATENCY_BUCKETS = ( 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, 25.0, 50.0, 75.0, float("inf"), ) if settings.configured: NAMESPACE = getattr(settings, "PROMETHEUS_METRIC_NAMESPACE", NAMESPACE) PROMETHEUS_LATENCY_BUCKETS = getattr(settings, "PROMETHEUS_LATENCY_BUCKETS", PROMETHEUS_LATENCY_BUCKETS) django-prometheus-2.3.1/django_prometheus/db/000077500000000000000000000000001442426466500212675ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/__init__.py000066400000000000000000000006231442426466500234010ustar00rootroot00000000000000# Import all metrics from django_prometheus.db.metrics import ( Counter, connection_errors_total, connections_total, errors_total, execute_many_total, execute_total, query_duration_seconds, ) __all__ = [ "Counter", "connection_errors_total", "connections_total", "errors_total", "execute_many_total", "execute_total", "query_duration_seconds", ] django-prometheus-2.3.1/django_prometheus/db/backends/000077500000000000000000000000001442426466500230415ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/README.md000066400000000000000000000027331442426466500243250ustar00rootroot00000000000000# Adding new database wrapper types Unfortunately, I don't have the resources to create wrappers for all database vendors. Doing so should be straightforward, but testing that it works and maintaining it is a lot of busywork, or is impossible for me for commercial databases. This document should be enough for people who wish to implement a new database wrapper. ## Structure A database engine in Django requires 3 classes (it really requires 2, but the 3rd one is required for our purposes): * A DatabaseFeatures class, which describes what features the database supports. For our usage, we can simply extend the existing DatabaseFeatures class without any changes. * A DatabaseWrapper class, which abstracts the interface to the database. * A CursorWrapper class, which abstracts the interface to a cursor. A cursor is the object that can execute SQL statements via an open connection. An easy example can be found in the sqlite3 module. Here are a few tips: * The `self.alias` and `self.vendor` properties are present in all DatabaseWrappers. * The CursorWrapper doesn't have access to the alias and vendor, so we generate the class in a function that accepts them as arguments. * Most methods you overload should just increment a counter, forward all arguments to the original method and return the result. `execute` and `execute_many` should also wrap the call to the parent method in a `try...except` block to increment the `errors_total` counter as appropriate. django-prometheus-2.3.1/django_prometheus/db/backends/__init__.py000066400000000000000000000000001442426466500251400ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/common.py000066400000000000000000000004001442426466500246750ustar00rootroot00000000000000from django import VERSION def get_postgres_cursor_class(): if VERSION < (4, 2): from psycopg2.extensions import cursor as cursor_cls else: from django.db.backends.postgresql.base import Cursor as cursor_cls return cursor_cls django-prometheus-2.3.1/django_prometheus/db/backends/mysql/000077500000000000000000000000001442426466500242065ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/mysql/__init__.py000066400000000000000000000000001442426466500263050ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/mysql/base.py000066400000000000000000000010751442426466500254750ustar00rootroot00000000000000from django.db.backends.mysql import base from django_prometheus.db.common import DatabaseWrapperMixin, ExportingCursorWrapper class DatabaseFeatures(base.DatabaseFeatures): """Our database has the exact same features as the base one.""" pass class DatabaseWrapper(DatabaseWrapperMixin, base.DatabaseWrapper): CURSOR_CLASS = base.CursorWrapper def create_cursor(self, name=None): cursor = self.connection.cursor() CursorWrapper = ExportingCursorWrapper(self.CURSOR_CLASS, self.alias, self.vendor) return CursorWrapper(cursor) django-prometheus-2.3.1/django_prometheus/db/backends/postgis/000077500000000000000000000000001442426466500245315ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/postgis/__init__.py000066400000000000000000000000001442426466500266300ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/postgis/base.py000066400000000000000000000014141442426466500260150ustar00rootroot00000000000000from django.contrib.gis.db.backends.postgis import base from django_prometheus.db.backends.common import get_postgres_cursor_class from django_prometheus.db.common import DatabaseWrapperMixin, ExportingCursorWrapper class DatabaseWrapper(DatabaseWrapperMixin, base.DatabaseWrapper): def get_new_connection(self, *args, **kwargs): conn = super().get_new_connection(*args, **kwargs) conn.cursor_factory = ExportingCursorWrapper( conn.cursor_factory or get_postgres_cursor_class(), "postgis", self.vendor ) return conn def create_cursor(self, name=None): # cursor_factory is a kwarg to connect() so restore create_cursor()'s # default behavior return base.DatabaseWrapper.create_cursor(self, name=name) django-prometheus-2.3.1/django_prometheus/db/backends/postgresql/000077500000000000000000000000001442426466500252445ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/postgresql/__init__.py000066400000000000000000000000001442426466500273430ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/postgresql/base.py000066400000000000000000000014041442426466500265270ustar00rootroot00000000000000from django.db.backends.postgresql import base from django_prometheus.db.backends.common import get_postgres_cursor_class from django_prometheus.db.common import DatabaseWrapperMixin, ExportingCursorWrapper class DatabaseWrapper(DatabaseWrapperMixin, base.DatabaseWrapper): def get_new_connection(self, *args, **kwargs): conn = super().get_new_connection(*args, **kwargs) conn.cursor_factory = ExportingCursorWrapper( conn.cursor_factory or get_postgres_cursor_class(), self.alias, self.vendor ) return conn def create_cursor(self, name=None): # cursor_factory is a kwarg to connect() so restore create_cursor()'s # default behavior return base.DatabaseWrapper.create_cursor(self, name=name) django-prometheus-2.3.1/django_prometheus/db/backends/sqlite3/000077500000000000000000000000001442426466500244255ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/sqlite3/__init__.py000066400000000000000000000000001442426466500265240ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/db/backends/sqlite3/base.py000066400000000000000000000005321442426466500257110ustar00rootroot00000000000000from django.db.backends.sqlite3 import base from django_prometheus.db.common import DatabaseWrapperMixin class DatabaseFeatures(base.DatabaseFeatures): """Our database has the exact same features as the base one.""" pass class DatabaseWrapper(DatabaseWrapperMixin, base.DatabaseWrapper): CURSOR_CLASS = base.SQLiteCursorWrapper django-prometheus-2.3.1/django_prometheus/db/common.py000066400000000000000000000052511442426466500231340ustar00rootroot00000000000000from django_prometheus.db import ( connection_errors_total, connections_total, errors_total, execute_many_total, execute_total, query_duration_seconds, ) class ExceptionCounterByType: """A context manager that counts exceptions by type. Exceptions increment the provided counter, whose last label's name must match the `type_label` argument. In other words: c = Counter('http_request_exceptions_total', 'Counter of exceptions', ['method', 'type']) with ExceptionCounterByType(c, extra_labels={'method': 'GET'}): handle_get_request() """ def __init__(self, counter, type_label="type", extra_labels=None): self._counter = counter self._type_label = type_label self._labels = dict(extra_labels) # Copy labels since we modify them. def __enter__(self): pass def __exit__(self, typ, value, traceback): if typ is not None: self._labels.update({self._type_label: typ.__name__}) self._counter.labels(**self._labels).inc() class DatabaseWrapperMixin: """Extends the DatabaseWrapper to count connections and cursors.""" def get_new_connection(self, *args, **kwargs): connections_total.labels(self.alias, self.vendor).inc() try: return super().get_new_connection(*args, **kwargs) except Exception: connection_errors_total.labels(self.alias, self.vendor).inc() raise def create_cursor(self, name=None): return self.connection.cursor(factory=ExportingCursorWrapper(self.CURSOR_CLASS, self.alias, self.vendor)) def ExportingCursorWrapper(cursor_class, alias, vendor): """Returns a CursorWrapper class that knows its database's alias and vendor name. """ labels = {"alias": alias, "vendor": vendor} class CursorWrapper(cursor_class): """Extends the base CursorWrapper to count events.""" def execute(self, *args, **kwargs): execute_total.labels(alias, vendor).inc() with query_duration_seconds.labels(**labels).time(), ExceptionCounterByType( errors_total, extra_labels=labels ): return super().execute(*args, **kwargs) def executemany(self, query, param_list, *args, **kwargs): execute_total.labels(alias, vendor).inc(len(param_list)) execute_many_total.labels(alias, vendor).inc(len(param_list)) with query_duration_seconds.labels(**labels).time(), ExceptionCounterByType( errors_total, extra_labels=labels ): return super().executemany(query, param_list, *args, **kwargs) return CursorWrapper django-prometheus-2.3.1/django_prometheus/db/metrics.py000066400000000000000000000025011442426466500233050ustar00rootroot00000000000000from prometheus_client import Counter, Histogram from django_prometheus.conf import NAMESPACE, PROMETHEUS_LATENCY_BUCKETS connections_total = Counter( "django_db_new_connections_total", "Counter of created connections by database and by vendor.", ["alias", "vendor"], namespace=NAMESPACE, ) connection_errors_total = Counter( "django_db_new_connection_errors_total", "Counter of connection failures by database and by vendor.", ["alias", "vendor"], namespace=NAMESPACE, ) execute_total = Counter( "django_db_execute_total", ("Counter of executed statements by database and by vendor, including" " bulk executions."), ["alias", "vendor"], namespace=NAMESPACE, ) execute_many_total = Counter( "django_db_execute_many_total", ("Counter of executed statements in bulk operations by database and" " by vendor."), ["alias", "vendor"], namespace=NAMESPACE, ) errors_total = Counter( "django_db_errors_total", ("Counter of execution errors by database, vendor and exception type."), ["alias", "vendor", "type"], namespace=NAMESPACE, ) query_duration_seconds = Histogram( "django_db_query_duration_seconds", ("Histogram of query duration by database and vendor."), ["alias", "vendor"], buckets=PROMETHEUS_LATENCY_BUCKETS, namespace=NAMESPACE, ) django-prometheus-2.3.1/django_prometheus/exports.py000066400000000000000000000112551442426466500227640ustar00rootroot00000000000000import logging import os import threading import prometheus_client from django.conf import settings from django.http import HttpResponse from prometheus_client import multiprocess try: # Python 2 from BaseHTTPServer import HTTPServer except ImportError: # Python 3 from http.server import HTTPServer logger = logging.getLogger(__name__) def SetupPrometheusEndpointOnPort(port, addr=""): """Exports Prometheus metrics on an HTTPServer running in its own thread. The server runs on the given port and is by default listenning on all interfaces. This HTTPServer is fully independent of Django and its stack. This offers the advantage that even if Django becomes unable to respond, the HTTPServer will continue to function and export metrics. However, this also means that the features offered by Django (like middlewares or WSGI) can't be used. Now here's the really weird part. When Django runs with the auto-reloader enabled (which is the default, you can disable it with `manage.py runserver --noreload`), it forks and executes manage.py twice. That's wasteful but usually OK. It starts being a problem when you try to open a port, like we do. We can detect that we're running under an autoreloader through the presence of the RUN_MAIN environment variable, so we abort if we're trying to export under an autoreloader and trying to open a port. """ assert os.environ.get("RUN_MAIN") != "true", ( "The thread-based exporter can't be safely used when django's " "autoreloader is active. Use the URL exporter, or start django " "with --noreload. See documentation/exports.md." ) prometheus_client.start_http_server(port, addr=addr) class PrometheusEndpointServer(threading.Thread): """A thread class that holds an http and makes it serve_forever().""" def __init__(self, httpd, *args, **kwargs): self.httpd = httpd super().__init__(*args, **kwargs) def run(self): self.httpd.serve_forever() def SetupPrometheusEndpointOnPortRange(port_range, addr=""): """Like SetupPrometheusEndpointOnPort, but tries several ports. This is useful when you're running Django as a WSGI application with multiple processes and you want Prometheus to discover all workers. Each worker will grab a port and you can use Prometheus to aggregate across workers. port_range may be any iterable object that contains a list of ports. Typically this would be a `range` of contiguous ports. As soon as one port is found that can serve, use this one and stop trying. Returns the port chosen (an `int`), or `None` if no port in the supplied range was available. The same caveats regarding autoreload apply. Do not use this when Django's autoreloader is active. """ assert os.environ.get("RUN_MAIN") != "true", ( "The thread-based exporter can't be safely used when django's " "autoreloader is active. Use the URL exporter, or start django " "with --noreload. See documentation/exports.md." ) for port in port_range: try: httpd = HTTPServer((addr, port), prometheus_client.MetricsHandler) except OSError: # Python 2 raises socket.error, in Python 3 socket.error is an # alias for OSError continue # Try next port thread = PrometheusEndpointServer(httpd) thread.daemon = True thread.start() logger.info("Exporting Prometheus /metrics/ on port %s" % port) return port # Stop trying ports at this point logger.warning("Cannot export Prometheus /metrics/ - " "no available ports in supplied range") return None def SetupPrometheusExportsFromConfig(): """Exports metrics so Prometheus can collect them.""" port = getattr(settings, "PROMETHEUS_METRICS_EXPORT_PORT", None) port_range = getattr(settings, "PROMETHEUS_METRICS_EXPORT_PORT_RANGE", None) addr = getattr(settings, "PROMETHEUS_METRICS_EXPORT_ADDRESS", "") if port_range: SetupPrometheusEndpointOnPortRange(port_range, addr) elif port: SetupPrometheusEndpointOnPort(port, addr) def ExportToDjangoView(request): """Exports /metrics as a Django view. You can use django_prometheus.urls to map /metrics to this view. """ if "PROMETHEUS_MULTIPROC_DIR" in os.environ or "prometheus_multiproc_dir" in os.environ: registry = prometheus_client.CollectorRegistry() multiprocess.MultiProcessCollector(registry) else: registry = prometheus_client.REGISTRY metrics_page = prometheus_client.generate_latest(registry) return HttpResponse(metrics_page, content_type=prometheus_client.CONTENT_TYPE_LATEST) django-prometheus-2.3.1/django_prometheus/middleware.py000066400000000000000000000273151442426466500234010ustar00rootroot00000000000000from django.utils.deprecation import MiddlewareMixin from prometheus_client import Counter, Histogram from django_prometheus.conf import NAMESPACE, PROMETHEUS_LATENCY_BUCKETS from django_prometheus.utils import PowersOf, Time, TimeSince class Metrics: _instance = None @classmethod def get_instance(cls): if not cls._instance: cls._instance = cls() return cls._instance def register_metric(self, metric_cls, name, documentation, labelnames=(), **kwargs): return metric_cls(name, documentation, labelnames=labelnames, **kwargs) def __init__(self, *args, **kwargs): self.register() def register(self): self.requests_total = self.register_metric( Counter, "django_http_requests_before_middlewares_total", "Total count of requests before middlewares run.", namespace=NAMESPACE, ) self.responses_total = self.register_metric( Counter, "django_http_responses_before_middlewares_total", "Total count of responses before middlewares run.", namespace=NAMESPACE, ) self.requests_latency_before = self.register_metric( Histogram, "django_http_requests_latency_including_middlewares_seconds", ("Histogram of requests processing time (including middleware " "processing time)."), buckets=PROMETHEUS_LATENCY_BUCKETS, namespace=NAMESPACE, ) self.requests_unknown_latency_before = self.register_metric( Counter, "django_http_requests_unknown_latency_including_middlewares_total", ( "Count of requests for which the latency was unknown (when computing " "django_http_requests_latency_including_middlewares_seconds)." ), namespace=NAMESPACE, ) self.requests_latency_by_view_method = self.register_metric( Histogram, "django_http_requests_latency_seconds_by_view_method", "Histogram of request processing time labelled by view.", ["view", "method"], buckets=PROMETHEUS_LATENCY_BUCKETS, namespace=NAMESPACE, ) self.requests_unknown_latency = self.register_metric( Counter, "django_http_requests_unknown_latency_total", "Count of requests for which the latency was unknown.", namespace=NAMESPACE, ) # Set in process_request self.requests_ajax = self.register_metric( Counter, "django_http_ajax_requests_total", "Count of AJAX requests.", namespace=NAMESPACE, ) self.requests_by_method = self.register_metric( Counter, "django_http_requests_total_by_method", "Count of requests by method.", ["method"], namespace=NAMESPACE, ) self.requests_by_transport = self.register_metric( Counter, "django_http_requests_total_by_transport", "Count of requests by transport.", ["transport"], namespace=NAMESPACE, ) # Set in process_view self.requests_by_view_transport_method = self.register_metric( Counter, "django_http_requests_total_by_view_transport_method", "Count of requests by view, transport, method.", ["view", "transport", "method"], namespace=NAMESPACE, ) self.requests_body_bytes = self.register_metric( Histogram, "django_http_requests_body_total_bytes", "Histogram of requests by body size.", buckets=PowersOf(2, 30), namespace=NAMESPACE, ) # Set in process_template_response self.responses_by_templatename = self.register_metric( Counter, "django_http_responses_total_by_templatename", "Count of responses by template name.", ["templatename"], namespace=NAMESPACE, ) # Set in process_response self.responses_by_status = self.register_metric( Counter, "django_http_responses_total_by_status", "Count of responses by status.", ["status"], namespace=NAMESPACE, ) self.responses_by_status_view_method = self.register_metric( Counter, "django_http_responses_total_by_status_view_method", "Count of responses by status, view, method.", ["status", "view", "method"], namespace=NAMESPACE, ) self.responses_body_bytes = self.register_metric( Histogram, "django_http_responses_body_total_bytes", "Histogram of responses by body size.", buckets=PowersOf(2, 30), namespace=NAMESPACE, ) self.responses_by_charset = self.register_metric( Counter, "django_http_responses_total_by_charset", "Count of responses by charset.", ["charset"], namespace=NAMESPACE, ) self.responses_streaming = self.register_metric( Counter, "django_http_responses_streaming_total", "Count of streaming responses.", namespace=NAMESPACE, ) # Set in process_exception self.exceptions_by_type = self.register_metric( Counter, "django_http_exceptions_total_by_type", "Count of exceptions by object type.", ["type"], namespace=NAMESPACE, ) self.exceptions_by_view = self.register_metric( Counter, "django_http_exceptions_total_by_view", "Count of exceptions by view.", ["view"], namespace=NAMESPACE, ) class PrometheusBeforeMiddleware(MiddlewareMixin): """Monitoring middleware that should run before other middlewares.""" metrics_cls = Metrics def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.metrics = self.metrics_cls.get_instance() def process_request(self, request): self.metrics.requests_total.inc() request.prometheus_before_middleware_event = Time() def process_response(self, request, response): self.metrics.responses_total.inc() if hasattr(request, "prometheus_before_middleware_event"): self.metrics.requests_latency_before.observe(TimeSince(request.prometheus_before_middleware_event)) else: self.metrics.requests_unknown_latency_before.inc() return response class PrometheusAfterMiddleware(MiddlewareMixin): """Monitoring middleware that should run after other middlewares.""" metrics_cls = Metrics def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.metrics = self.metrics_cls.get_instance() def _transport(self, request): return "https" if request.is_secure() else "http" def _method(self, request): m = request.method if m not in ( "GET", "HEAD", "POST", "PUT", "DELETE", "TRACE", "OPTIONS", "CONNECT", "PATCH", ): return "" return m def label_metric(self, metric, request, response=None, **labels): return metric.labels(**labels) if labels else metric def process_request(self, request): transport = self._transport(request) method = self._method(request) self.label_metric(self.metrics.requests_by_method, request, method=method).inc() self.label_metric(self.metrics.requests_by_transport, request, transport=transport).inc() # Mimic the behaviour of the deprecated "Request.is_ajax()" method. if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest": self.label_metric(self.metrics.requests_ajax, request).inc() content_length = int(request.META.get("CONTENT_LENGTH") or 0) self.label_metric(self.metrics.requests_body_bytes, request).observe(content_length) request.prometheus_after_middleware_event = Time() def _get_view_name(self, request): view_name = "" if hasattr(request, "resolver_match"): if request.resolver_match is not None: if request.resolver_match.view_name is not None: view_name = request.resolver_match.view_name return view_name def process_view(self, request, view_func, *view_args, **view_kwargs): transport = self._transport(request) method = self._method(request) if hasattr(request, "resolver_match"): name = request.resolver_match.view_name or "" self.label_metric( self.metrics.requests_by_view_transport_method, request, view=name, transport=transport, method=method, ).inc() def process_template_response(self, request, response): if hasattr(response, "template_name"): self.label_metric( self.metrics.responses_by_templatename, request, response=response, templatename=str(response.template_name), ).inc() return response def process_response(self, request, response): method = self._method(request) name = self._get_view_name(request) status = str(response.status_code) self.label_metric(self.metrics.responses_by_status, request, response, status=status).inc() self.label_metric( self.metrics.responses_by_status_view_method, request, response, status=status, view=name, method=method, ).inc() if hasattr(response, "charset"): self.label_metric( self.metrics.responses_by_charset, request, response, charset=str(response.charset), ).inc() if hasattr(response, "streaming") and response.streaming: self.label_metric(self.metrics.responses_streaming, request, response).inc() if hasattr(response, "content"): self.label_metric(self.metrics.responses_body_bytes, request, response).observe(len(response.content)) if hasattr(request, "prometheus_after_middleware_event"): self.label_metric( self.metrics.requests_latency_by_view_method, request, response, view=self._get_view_name(request), method=request.method, ).observe(TimeSince(request.prometheus_after_middleware_event)) else: self.label_metric(self.metrics.requests_unknown_latency, request, response).inc() return response def process_exception(self, request, exception): self.label_metric(self.metrics.exceptions_by_type, request, type=type(exception).__name__).inc() if hasattr(request, "resolver_match"): name = request.resolver_match.view_name or "" self.label_metric(self.metrics.exceptions_by_view, request, view=name).inc() if hasattr(request, "prometheus_after_middleware_event"): self.label_metric( self.metrics.requests_latency_by_view_method, request, view=self._get_view_name(request), method=request.method, ).observe(TimeSince(request.prometheus_after_middleware_event)) else: self.label_metric(self.metrics.requests_unknown_latency, request).inc() django-prometheus-2.3.1/django_prometheus/migrations.py000066400000000000000000000035511442426466500234340ustar00rootroot00000000000000from django.db import connections from django.db.backends.dummy.base import DatabaseWrapper from prometheus_client import Gauge from django_prometheus.conf import NAMESPACE unapplied_migrations = Gauge( "django_migrations_unapplied_total", "Count of unapplied migrations by database connection", ["connection"], namespace=NAMESPACE, ) applied_migrations = Gauge( "django_migrations_applied_total", "Count of applied migrations by database connection", ["connection"], namespace=NAMESPACE, ) def ExportMigrationsForDatabase(alias, executor): plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) unapplied_migrations.labels(alias).set(len(plan)) applied_migrations.labels(alias).set(len(executor.loader.applied_migrations)) def ExportMigrations(): """Exports counts of unapplied migrations. This is meant to be called during app startup, ideally by django_prometheus.apps.AppConfig. """ # Import MigrationExecutor lazily. MigrationExecutor checks at # import time that the apps are ready, and they are not when # django_prometheus is imported. ExportMigrations() should be # called in AppConfig.ready(), which signals that all apps are # ready. from django.db.migrations.executor import MigrationExecutor if "default" in connections and (isinstance(connections["default"], DatabaseWrapper)): # This is the case where DATABASES = {} in the configuration, # i.e. the user is not using any databases. Django "helpfully" # adds a dummy database and then throws when you try to # actually use it. So we don't do anything, because trying to # export stats would crash the app on startup. return for alias in connections.databases: executor = MigrationExecutor(connections[alias]) ExportMigrationsForDatabase(alias, executor) django-prometheus-2.3.1/django_prometheus/models.py000066400000000000000000000030151442426466500225360ustar00rootroot00000000000000from prometheus_client import Counter from django_prometheus.conf import NAMESPACE model_inserts = Counter( "django_model_inserts_total", "Number of insert operations by model.", ["model"], namespace=NAMESPACE, ) model_updates = Counter( "django_model_updates_total", "Number of update operations by model.", ["model"], namespace=NAMESPACE, ) model_deletes = Counter( "django_model_deletes_total", "Number of delete operations by model.", ["model"], namespace=NAMESPACE, ) def ExportModelOperationsMixin(model_name): """Returns a mixin for models to export counters for lifecycle operations. Usage: class User(ExportModelOperationsMixin('user'), Model): ... """ # Force create the labels for this model in the counters. This # is not necessary but it avoids gaps in the aggregated data. model_inserts.labels(model_name) model_updates.labels(model_name) model_deletes.labels(model_name) class Mixin: def _do_insert(self, *args, **kwargs): model_inserts.labels(model_name).inc() return super()._do_insert(*args, **kwargs) def _do_update(self, *args, **kwargs): model_updates.labels(model_name).inc() return super()._do_update(*args, **kwargs) def delete(self, *args, **kwargs): model_deletes.labels(model_name).inc() return super().delete(*args, **kwargs) Mixin.__qualname__ = f"ExportModelOperationsMixin('{model_name}')" return Mixin django-prometheus-2.3.1/django_prometheus/tests/000077500000000000000000000000001442426466500220445ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/tests/__init__.py000066400000000000000000000000001442426466500241430ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/tests/end2end/000077500000000000000000000000001442426466500233635ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/tests/end2end/manage.py000077500000000000000000000003721442426466500251720ustar00rootroot00000000000000#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/000077500000000000000000000000001442426466500250435ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/__init__.py000066400000000000000000000000001442426466500271420ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/helpers.py000066400000000000000000000011011442426466500270500ustar00rootroot00000000000000DJANGO_MIDDLEWARES = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.security.SecurityMiddleware", ] def get_middleware(before, after): middleware = [before] middleware.extend(DJANGO_MIDDLEWARES) middleware.append(after) return middleware django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/models.py000066400000000000000000000007031442426466500267000ustar00rootroot00000000000000from django.db.models import CharField, Model, PositiveIntegerField from django_prometheus.models import ExportModelOperationsMixin class Dog(ExportModelOperationsMixin("dog"), Model): name = CharField(max_length=100, unique=True) breed = CharField(max_length=100, blank=True, null=True) age = PositiveIntegerField(blank=True, null=True) class Lawn(ExportModelOperationsMixin("lawn"), Model): location = CharField(max_length=100) django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/settings.py000066400000000000000000000104161442426466500272570ustar00rootroot00000000000000import os import tempfile from testapp.helpers import get_middleware # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = ")0-t%mc5y1^fn8e7i**^^v166@5iu(&-2%9#kxud0&4ap#k!_k" DEBUG = True ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = ( "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django_prometheus", "testapp", ) MIDDLEWARE = get_middleware( "django_prometheus.middleware.PrometheusBeforeMiddleware", "django_prometheus.middleware.PrometheusAfterMiddleware", ) ROOT_URLCONF = "testapp.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ] }, } ] WSGI_APPLICATION = "testapp.wsgi.application" DATABASES = { "default": { "ENGINE": "django_prometheus.db.backends.sqlite3", "NAME": "db.sqlite3", }, # Comment this to not test django_prometheus.db.backends.postgres. "postgresql": { "ENGINE": "django_prometheus.db.backends.postgresql", "NAME": "postgres", "USER": "postgres", "PASSWORD": "", "HOST": "localhost", "PORT": "5432", }, # Comment this to not test django_prometheus.db.backends.postgis. "postgis": { "ENGINE": "django_prometheus.db.backends.postgis", "NAME": "postgis", "USER": "postgres", "PASSWORD": "", "HOST": "localhost", "PORT": "5432", }, # Comment this to not test django_prometheus.db.backends.mysql. "mysql": { "ENGINE": "django_prometheus.db.backends.mysql", "NAME": "django_prometheus_1", "USER": "root", "PASSWORD": "", "HOST": "127.0.0.1", "PORT": "3306", }, # The following databases are used by test_db.py only "test_db_1": { "ENGINE": "django_prometheus.db.backends.sqlite3", "NAME": "test_db_1.sqlite3", }, "test_db_2": { "ENGINE": "django_prometheus.db.backends.sqlite3", "NAME": "test_db_2.sqlite3", }, } # Caches _tmp_cache_dir = tempfile.mkdtemp() CACHES = { "default": { "BACKEND": "django_prometheus.cache.backends.memcached.PyLibMCCache", "LOCATION": "localhost:11211", }, "memcached.PyLibMCCache": { "BACKEND": "django_prometheus.cache.backends.memcached.PyLibMCCache", "LOCATION": "localhost:11211", }, "memcached.PyMemcacheCache": { "BACKEND": "django_prometheus.cache.backends.memcached.PyMemcacheCache", "LOCATION": "localhost:11211", }, "filebased": { "BACKEND": "django_prometheus.cache.backends.filebased.FileBasedCache", "LOCATION": os.path.join(_tmp_cache_dir, "django_cache"), }, "locmem": { "BACKEND": "django_prometheus.cache.backends.locmem.LocMemCache", "LOCATION": os.path.join(_tmp_cache_dir, "locmem_cache"), }, "redis": { "BACKEND": "django_prometheus.cache.backends.redis.RedisCache", "LOCATION": "redis://127.0.0.1:6379/1", }, # Fake redis config emulated stopped service "stopped_redis": { "BACKEND": "django_prometheus.cache.backends.redis.RedisCache", "LOCATION": "redis://127.0.0.1:6666/1", }, "stopped_redis_ignore_exception": { "BACKEND": "django_prometheus.cache.backends.redis.RedisCache", "LOCATION": "redis://127.0.0.1:6666/1", "OPTIONS": {"IGNORE_EXCEPTIONS": True}, }, } # Internationalization LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_TZ = False # Static files (CSS, JavaScript, Images) STATIC_URL = "/static/" LOGGING = { "version": 1, "disable_existing_loggers": False, "handlers": {"console": {"class": "logging.StreamHandler"}}, "root": {"handlers": ["console"], "level": "INFO"}, "loggers": {"django": {"handlers": ["console"], "level": "INFO"}}, } django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/templates/000077500000000000000000000000001442426466500270415ustar00rootroot00000000000000django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/templates/help.html000066400000000000000000000041261442426466500306620ustar00rootroot00000000000000

Can't Help Falling in Love

Remembering Helps Me to Forget

Helplessly, Hopelessly

Love Helps Those

I Need a Little Help

For a while We Helped Each Other Out

Give Me a Helping Hand

I Can't Help You, I'm Falling Too

How Can I Help You Say Goodbye?

Time Hasn't Helped

Jukebox, Help Me Find My Baby

I Just Can't Help Myself

Help Me, Girl

I Can't Help it

Help Somebody

Help, Help

I Can't Help How I Feel

No Help From Me

I Can Help

Somebody Help Me

Please Help Me I'm Falling in Love With You

Help Yourself

Outside Help

Helping Hand

Help Me, Rhonda

Can't Help Feeling So Blue

We All Agreed to Help

Help Pour Out the Rain (Lacey's Song)

Sleep Won't Help Me

I Can't Help Myself (Sugarpie, Honeybunch)

Cry for Help

She's Helping Me Get Over You

Mama Help Me

Help Yourself to Me

Can't Help But Wonder

Heaven Help the Working Girl

Help Me Pick Up the Pieces

Crying Won't Help Now

I Couldn't Help Myself

So Help Me, Girl

Heaven Help the Fool

Help Wanted

Help Me Get Over You

Helpless

Help

Can't Help it

Can't Help Calling Your Name

If She Just Helps Me Get Over You

Helpless Heart

No Help Wanted

It Didn't Help Much

Help Me Make it Through the Night

Help Me Understand

I Just Can't Help Believing

Can't Help Thinking About Me

How Could I Help But Love You?

Heaven Help My Heart

I Can't Help Remembering You

Help Me Hold on

Helping Me Get Over You

I Can't Help it if I'm Still in Love with You

Girl Can't Help it, The

I Can't Help it, I'm Falling in Love

With a Little Help from My Friends

Heaven Help the Child

Help Me

Can't Help But Love You

Help is on the Way

I Got Some Help I Don't Need

Heaven Help Us All

Heaven Help Me

Helplessly Hoping

django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/templates/index.html000066400000000000000000000000231442426466500310310ustar00rootroot00000000000000This is the index. django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/templates/lawn.html000066400000000000000000000001051442426466500306640ustar00rootroot00000000000000

Aaah, {{ lawn.location }}, the best place on Earth, probably.

django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/templates/slow.html000066400000000000000000000042521442426466500307160ustar00rootroot00000000000000
                                  _.---"'"""""'`--.._
                             _,.-'                   `-._
                         _,."                            -.
                     .-""   ___...---------.._             `.
                     `---'""                  `-.            `.
                                                 `.            \
                                                   `.           \
                                                     \           \
                                                      .           \
                                                      |            .
                                                      |            |
                                _________             |            |
                          _,.-'"         `"'-.._      :            |
                      _,-'                      `-._.'             |
                   _.'                              `.             '
        _.-.    _,+......__                           `.          .
      .'    `-"'           `"-.,-""--._                 \        /
     /    ,'                  |    __  \                 \      /
    `   ..                       +"  )  \                 \    /
     `.'  \          ,-"`-..    |       |                  \  /
      / " |        .'       \   '.    _.'                   .'
     |,.."--"""--..|    "    |    `""`.                     |
   ,"               `-._     |        |                     |
 .'                     `-._+         |                     |
/                           `.                        /     |
|    `     '                  |                      /      |
`-.....--.__                  |              |      /       |
   `./ "| / `-.........--.-   '              |    ,'        '
     /| ||        `.'  ,'   .'               |_,-+         /
    / ' '.`.        _,'   ,'     `.          |   '   _,.. /
   /   `.  `"'"'""'"   _,^--------"`.        |    `.'_  _/
  /... _.`:.________,.'              `._,.-..|        "'
 `.__.'                                 `._  /
                                           "' mh
Art by Maija Haavisto django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/templates/sql.html000066400000000000000000000012441442426466500305270ustar00rootroot00000000000000

Execute some SQL here, for fun and profit!

Note that this is a very bad vulnerability: it gives anyone direct access to your whole database. This only exists to test that django_prometheus is working.

{% if query %}

Your query was:

{{ query }}

Your results were:

{{ rows }}

{% endif %} django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/test_caches.py000066400000000000000000000065501442426466500277100ustar00rootroot00000000000000import pytest from django.core.cache import caches from redis import RedisError from django_prometheus.testutils import assert_metric_equal, get_metric _SUPPORTED_CACHES = ["memcached.PyLibMCCache", "memcached.PyMemcacheCache", "filebased", "locmem", "redis"] class TestCachesMetrics: """Test django_prometheus.caches metrics.""" @pytest.mark.parametrize("supported_cache", _SUPPORTED_CACHES) def test_counters(self, supported_cache): # Note: those tests require a memcached server running tested_cache = caches[supported_cache] backend = supported_cache.split(".")[0] total_before = get_metric("django_cache_get_total", backend=backend) or 0 hit_before = get_metric("django_cache_get_hits_total", backend=backend) or 0 miss_before = get_metric("django_cache_get_misses_total", backend=backend) or 0 tested_cache.set("foo1", "bar") tested_cache.get("foo1") tested_cache.get("foo1") tested_cache.get("foofoo") result = tested_cache.get("foofoo", default="default") assert result == "default" assert_metric_equal(total_before + 4, "django_cache_get_total", backend=backend) assert_metric_equal(hit_before + 2, "django_cache_get_hits_total", backend=backend) assert_metric_equal( miss_before + 2, "django_cache_get_misses_total", backend=backend, ) def test_redis_cache_fail(self): # Note: test use fake service config (like if server was stopped) supported_cache = "redis" total_before = get_metric("django_cache_get_total", backend=supported_cache) or 0 fail_before = get_metric("django_cache_get_fail_total", backend=supported_cache) or 0 hit_before = get_metric("django_cache_get_hits_total", backend=supported_cache) or 0 miss_before = get_metric("django_cache_get_misses_total", backend=supported_cache) or 0 tested_cache = caches["stopped_redis_ignore_exception"] tested_cache.get("foo1") assert_metric_equal(hit_before, "django_cache_get_hits_total", backend=supported_cache) assert_metric_equal(miss_before, "django_cache_get_misses_total", backend=supported_cache) assert_metric_equal(total_before + 1, "django_cache_get_total", backend=supported_cache) assert_metric_equal(fail_before + 1, "django_cache_get_fail_total", backend=supported_cache) tested_cache = caches["stopped_redis"] with pytest.raises(RedisError): tested_cache.get("foo1") assert_metric_equal(hit_before, "django_cache_get_hits_total", backend=supported_cache) assert_metric_equal(miss_before, "django_cache_get_misses_total", backend=supported_cache) assert_metric_equal(total_before + 2, "django_cache_get_total", backend=supported_cache) assert_metric_equal(fail_before + 2, "django_cache_get_fail_total", backend=supported_cache) @pytest.mark.parametrize("supported_cache", _SUPPORTED_CACHES) def test_cache_version_support(self, supported_cache): # Note: those tests require a memcached server running tested_cache = caches[supported_cache] tested_cache.set("foo1", "bar v.1", version=1) tested_cache.set("foo1", "bar v.2", version=2) assert "bar v.1" == tested_cache.get("foo1", version=1) assert "bar v.2" == tested_cache.get("foo1", version=2) django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/test_db.py000066400000000000000000000135511442426466500270460ustar00rootroot00000000000000from unittest import skipUnless from django.db import connections from django.test import TestCase from django_prometheus.testutils import ( assert_metric_compare, assert_metric_diff, assert_metric_equal, get_metric, save_registry, ) class BaseDbMetricTest(TestCase): # https://docs.djangoproject.com/en/2.2/topics/testing/tools/#django.test.SimpleTestCase.databases databases = "__all__" @skipUnless(connections["test_db_1"].vendor == "sqlite", "Skipped unless test_db_1 uses sqlite") class TestDbMetrics(BaseDbMetricTest): """Test django_prometheus.db metrics. Note regarding the values of metrics: many tests interact with the database, and the test runner itself does. As such, tests that require that a metric has a specific value are at best very fragile. Consider asserting that the value exceeds a certain threshold, or check by how much it increased during the test. """ def test_config_has_expected_databases(self): """Not a real unit test: ensures that testapp.settings contains the databases this test expects.""" assert "default" in connections.databases.keys() assert "test_db_1" in connections.databases.keys() assert "test_db_2" in connections.databases.keys() def test_counters(self): cursor_db1 = connections["test_db_1"].cursor() cursor_db2 = connections["test_db_2"].cursor() cursor_db1.execute("SELECT 1") for _ in range(200): cursor_db2.execute("SELECT 2") cursor_db1.execute("SELECT 3") try: cursor_db1.execute("this is clearly not valid SQL") except Exception: pass assert_metric_equal( 1, "django_db_errors_total", alias="test_db_1", vendor="sqlite", type="OperationalError", ) assert get_metric("django_db_execute_total", alias="test_db_1", vendor="sqlite") > 0 assert get_metric("django_db_execute_total", alias="test_db_2", vendor="sqlite") >= 200 def test_histograms(self): cursor_db1 = connections["test_db_1"].cursor() cursor_db2 = connections["test_db_2"].cursor() cursor_db1.execute("SELECT 1") for _ in range(200): cursor_db2.execute("SELECT 2") assert ( get_metric( "django_db_query_duration_seconds_count", alias="test_db_1", vendor="sqlite", ) > 0 ) assert ( get_metric( "django_db_query_duration_seconds_count", alias="test_db_2", vendor="sqlite", ) >= 200 ) def test_execute_many(self): registry = save_registry() cursor_db1 = connections["test_db_1"].cursor() cursor_db1.executemany( "INSERT INTO testapp_lawn(location) VALUES (?)", [("Paris",), ("New York",), ("Berlin",), ("San Francisco",)], ) assert_metric_diff( registry, 4, "django_db_execute_many_total", alias="test_db_1", vendor="sqlite", ) @skipUnless("postgresql" in connections, "Skipped unless postgresql database is enabled") class TestPostgresDbMetrics(BaseDbMetricTest): """Test django_prometheus.db metrics for postgres backend. Note regarding the values of metrics: many tests interact with the database, and the test runner itself does. As such, tests that require that a metric has a specific value are at best very fragile. Consider asserting that the value exceeds a certain threshold, or check by how much it increased during the test. """ def test_counters(self): registry = save_registry() cursor = connections["postgresql"].cursor() for _ in range(20): cursor.execute("SELECT 1") assert_metric_compare( registry, lambda a, b: a + 20 <= b < a + 25, "django_db_execute_total", alias="postgresql", vendor="postgresql", ) @skipUnless("mysql" in connections, "Skipped unless mysql database is enabled") class TestMysDbMetrics(BaseDbMetricTest): """Test django_prometheus.db metrics for mys backend. Note regarding the values of metrics: many tests interact with the database, and the test runner itself does. As such, tests that require that a metric has a specific value are at best very fragile. Consider asserting that the value exceeds a certain threshold, or check by how much it increased during the test. """ def test_counters(self): registry = save_registry() cursor = connections["mysql"].cursor() for _ in range(20): cursor.execute("SELECT 1") assert_metric_compare( registry, lambda a, b: a + 20 <= b < a + 25, "django_db_execute_total", alias="mysql", vendor="mysql", ) @skipUnless("postgis" in connections, "Skipped unless postgis database is enabled") class TestPostgisDbMetrics(BaseDbMetricTest): """Test django_prometheus.db metrics for postgis backend. Note regarding the values of metrics: many tests interact with the database, and the test runner itself does. As such, tests that require that a metric has a specific value are at best very fragile. Consider asserting that the value exceeds a certain threshold, or check by how much it increased during the test. """ def test_counters(self): r = save_registry() cursor = connections["postgis"].cursor() for _ in range(20): cursor.execute("SELECT 1") assert_metric_compare( r, lambda a, b: a + 20 <= b < a + 25, "django_db_execute_total", alias="postgis", vendor="postgresql", ) django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/test_middleware.py000066400000000000000000000116041442426466500305730ustar00rootroot00000000000000import pytest from testapp.views import ObjectionException from django_prometheus.testutils import ( assert_metric_diff, assert_metric_equal, save_registry, ) def M(metric_name): """Makes a full metric name from a short metric name. This is just intended to help keep the lines shorter in test cases. """ return "django_http_%s" % metric_name def T(metric_name): """Makes a full metric name from a short metric name like M(metric_name) This method adds a '_total' postfix for metrics.""" return "%s_total" % M(metric_name) class TestMiddlewareMetrics: """Test django_prometheus.middleware. Note that counters related to exceptions can't be tested as Django's test Client only simulates requests and the exception handling flow is very different in that simulation. """ @pytest.fixture(autouse=True) def _setup(self, settings): settings.PROMETHEUS_LATENCY_BUCKETS = (0.05, 1.0, 2.0, 4.0, 5.0, 10.0, float("inf")) def test_request_counters(self, client): registry = save_registry() client.get("/") client.get("/") client.get("/help") client.post("/", {"test": "data"}) assert_metric_diff(registry, 4, M("requests_before_middlewares_total")) assert_metric_diff(registry, 4, M("responses_before_middlewares_total")) assert_metric_diff(registry, 3, T("requests_total_by_method"), method="GET") assert_metric_diff(registry, 1, T("requests_total_by_method"), method="POST") assert_metric_diff(registry, 4, T("requests_total_by_transport"), transport="http") assert_metric_diff( registry, 2, T("requests_total_by_view_transport_method"), view="testapp.views.index", transport="http", method="GET", ) assert_metric_diff( registry, 1, T("requests_total_by_view_transport_method"), view="testapp.views.help", transport="http", method="GET", ) assert_metric_diff( registry, 1, T("requests_total_by_view_transport_method"), view="testapp.views.index", transport="http", method="POST", ) # We have 3 requests with no post body, and one with a few # bytes, but buckets are cumulative so that is 4 requests with # <=128 bytes bodies. assert_metric_diff(registry, 3, M("requests_body_total_bytes_bucket"), le="0.0") assert_metric_diff(registry, 4, M("requests_body_total_bytes_bucket"), le="128.0") assert_metric_equal(None, M("responses_total_by_templatename"), templatename="help.html") assert_metric_diff(registry, 3, T("responses_total_by_templatename"), templatename="index.html") assert_metric_diff(registry, 4, T("responses_total_by_status"), status="200") assert_metric_diff(registry, 0, M("responses_body_total_bytes_bucket"), le="0.0") assert_metric_diff(registry, 3, M("responses_body_total_bytes_bucket"), le="128.0") assert_metric_diff(registry, 4, M("responses_body_total_bytes_bucket"), le="8192.0") assert_metric_diff(registry, 4, T("responses_total_by_charset"), charset="utf-8") assert_metric_diff(registry, 0, M("responses_streaming_total")) def test_latency_histograms(self, client): # Caution: this test is timing-based. This is not ideal. It # runs slowly (each request to /slow takes at least .1 seconds # to complete), to eliminate flakiness we adjust the buckets used # in the test suite. registry = save_registry() # This always takes more than .1 second, so checking the lower # buckets is fine. client.get("/slow") assert_metric_diff( registry, 0, M("requests_latency_seconds_by_view_method_bucket"), le="0.05", view="slow", method="GET", ) assert_metric_diff( registry, 1, M("requests_latency_seconds_by_view_method_bucket"), le="5.0", view="slow", method="GET", ) def test_exception_latency_histograms(self, client): registry = save_registry() try: client.get("/objection") except ObjectionException: pass assert_metric_diff( registry, 2, M("requests_latency_seconds_by_view_method_bucket"), le="2.5", view="testapp.views.objection", method="GET", ) def test_streaming_responses(self, client): registry = save_registry() client.get("/") client.get("/file") assert_metric_diff(registry, 1, M("responses_streaming_total")) assert_metric_diff(registry, 1, M("responses_body_total_bytes_bucket"), le="+Inf") django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/test_middleware_custom_labels.py000066400000000000000000000073641442426466500335170ustar00rootroot00000000000000import pytest from prometheus_client import REGISTRY from prometheus_client.metrics import MetricWrapperBase from testapp.helpers import get_middleware from testapp.test_middleware import M, T from django_prometheus.middleware import ( Metrics, PrometheusAfterMiddleware, PrometheusBeforeMiddleware, ) from django_prometheus.testutils import assert_metric_diff, save_registry EXTENDED_METRICS = [ M("requests_latency_seconds_by_view_method"), M("responses_total_by_status_view_method"), ] class CustomMetrics(Metrics): def register_metric(self, metric_cls, name, documentation, labelnames=(), **kwargs): if name in EXTENDED_METRICS: labelnames.extend(("view_type", "user_agent_type")) return super().register_metric(metric_cls, name, documentation, labelnames=labelnames, **kwargs) class AppMetricsBeforeMiddleware(PrometheusBeforeMiddleware): metrics_cls = CustomMetrics class AppMetricsAfterMiddleware(PrometheusAfterMiddleware): metrics_cls = CustomMetrics def label_metric(self, metric, request, response=None, **labels): new_labels = labels if metric._name in EXTENDED_METRICS: new_labels = {"view_type": "foo", "user_agent_type": "browser"} new_labels.update(labels) return super().label_metric(metric, request, response=response, **new_labels) class TestMiddlewareMetricsWithCustomLabels: @pytest.fixture(autouse=True) def _setup(self, settings): settings.MIDDLEWARE = get_middleware( "testapp.test_middleware_custom_labels.AppMetricsBeforeMiddleware", "testapp.test_middleware_custom_labels.AppMetricsAfterMiddleware", ) # Allow CustomMetrics to be used for metric in Metrics._instance.__dict__.values(): if isinstance(metric, MetricWrapperBase): REGISTRY.unregister(metric) Metrics._instance = None def test_request_counters(self, client): registry = save_registry() client.get("/") client.get("/") client.get("/help") client.post("/", {"test": "data"}) assert_metric_diff(registry, 4, M("requests_before_middlewares_total")) assert_metric_diff(registry, 4, M("responses_before_middlewares_total")) assert_metric_diff(registry, 3, T("requests_total_by_method"), method="GET") assert_metric_diff(registry, 1, T("requests_total_by_method"), method="POST") assert_metric_diff(registry, 4, T("requests_total_by_transport"), transport="http") assert_metric_diff( registry, 2, T("requests_total_by_view_transport_method"), view="testapp.views.index", transport="http", method="GET", ) assert_metric_diff( registry, 1, T("requests_total_by_view_transport_method"), view="testapp.views.help", transport="http", method="GET", ) assert_metric_diff( registry, 1, T("requests_total_by_view_transport_method"), view="testapp.views.index", transport="http", method="POST", ) assert_metric_diff( registry, 2.0, T("responses_total_by_status_view_method"), status="200", view="testapp.views.index", method="GET", view_type="foo", user_agent_type="browser", ) assert_metric_diff( registry, 1.0, T("responses_total_by_status_view_method"), status="200", view="testapp.views.help", method="GET", view_type="foo", user_agent_type="browser", ) django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/test_migrations.py000066400000000000000000000024471442426466500306370ustar00rootroot00000000000000from unittest.mock import MagicMock import pytest from django_prometheus.migrations import ExportMigrationsForDatabase from django_prometheus.testutils import assert_metric_equal def M(metric_name): """Make a full metric name from a short metric name. This is just intended to help keep the lines shorter in test cases. """ return "django_migrations_%s" % metric_name @pytest.mark.django_db() class TestMigrations: """Test migration counters.""" def test_counters(self): executor = MagicMock() executor.migration_plan = MagicMock() executor.migration_plan.return_value = set() executor.loader.applied_migrations = {"a", "b", "c"} ExportMigrationsForDatabase("fakedb1", executor) assert executor.migration_plan.call_count == 1 executor.migration_plan = MagicMock() executor.migration_plan.return_value = {"a"} executor.loader.applied_migrations = {"b", "c"} ExportMigrationsForDatabase("fakedb2", executor) assert_metric_equal(3, M("applied_total"), connection="fakedb1") assert_metric_equal(0, M("unapplied_total"), connection="fakedb1") assert_metric_equal(2, M("applied_total"), connection="fakedb2") assert_metric_equal(1, M("unapplied_total"), connection="fakedb2") django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/test_models.py000066400000000000000000000031751442426466500277450ustar00rootroot00000000000000import pytest from testapp.models import Dog, Lawn from django_prometheus.testutils import assert_metric_diff, save_registry def M(metric_name): """Make a full metric name from a short metric name. This is just intended to help keep the lines shorter in test cases. """ return "django_model_%s" % metric_name @pytest.mark.django_db() class TestModelMetrics: """Test django_prometheus.models.""" def test_counters(self): registry = save_registry() cool = Dog() cool.name = "Cool" cool.save() assert_metric_diff(registry, 1, M("inserts_total"), model="dog") elysees = Lawn() elysees.location = "Champs Elysees, Paris" elysees.save() assert_metric_diff(registry, 1, M("inserts_total"), model="lawn") assert_metric_diff(registry, 1, M("inserts_total"), model="dog") galli = Dog() galli.name = "Galli" galli.save() assert_metric_diff(registry, 2, M("inserts_total"), model="dog") cool.breed = "Wolfhound" assert_metric_diff(registry, 2, M("inserts_total"), model="dog") cool.save() assert_metric_diff(registry, 2, M("inserts_total"), model="dog") assert_metric_diff(registry, 1, M("updates_total"), model="dog") cool.age = 9 cool.save() assert_metric_diff(registry, 2, M("updates_total"), model="dog") cool.delete() # :( assert_metric_diff(registry, 2, M("inserts_total"), model="dog") assert_metric_diff(registry, 2, M("updates_total"), model="dog") assert_metric_diff(registry, 1, M("deletes_total"), model="dog") django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/urls.py000066400000000000000000000010101442426466500263720ustar00rootroot00000000000000from django.contrib import admin from django.urls import include, path, re_path from testapp import views urlpatterns = [ re_path(r"^$", views.index), re_path(r"^help$", views.help), re_path(r"^slow$", views.slow, name="slow"), re_path(r"^objection$", views.objection), re_path(r"^sql$", views.sql), re_path(r"^newlawn/(?P[a-zA-Z0-9 ]+)$", views.newlawn), re_path(r"^file$", views.file), path("", include("django_prometheus.urls")), re_path(r"^admin/", admin.site.urls), ] django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/views.py000066400000000000000000000030601442426466500265510ustar00rootroot00000000000000import os import time from django.db import connections from django.http import FileResponse from django.shortcuts import render from django.template.response import TemplateResponse from testapp.models import Lawn def index(request): return TemplateResponse(request, "index.html", {}) def help(request): # render does not instantiate a TemplateResponse, so it does not # increment the "by_templatename" counters. return render(request, "help.html", {}) def slow(request): """This view takes .1s to load, on purpose.""" time.sleep(0.1) return TemplateResponse(request, "slow.html", {}) def newlawn(request, location): """This view creates a new Lawn instance in the database.""" lawn = Lawn() lawn.location = location lawn.save() return TemplateResponse(request, "lawn.html", {"lawn": lawn}) class ObjectionException(Exception): pass def objection(request): raise ObjectionException("Objection!") def sql(request): databases = connections.databases.keys() query = request.GET.get("query") db = request.GET.get("database") if query and db: cursor = connections[db].cursor() cursor.execute(query, []) results = cursor.fetchall() return TemplateResponse( request, "sql.html", {"query": query, "rows": results, "databases": databases}, ) else: return TemplateResponse(request, "sql.html", {"query": None, "rows": None, "databases": databases}) def file(request): return FileResponse(open(os.devnull, "rb")) django-prometheus-2.3.1/django_prometheus/tests/end2end/testapp/wsgi.py000066400000000000000000000006071442426466500263710ustar00rootroot00000000000000""" WSGI config for testapp project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") application = get_wsgi_application() django-prometheus-2.3.1/django_prometheus/tests/test_django_prometheus.py000066400000000000000000000006531442426466500271760ustar00rootroot00000000000000#!/usr/bin/env python from django_prometheus.utils import PowersOf class TestDjangoPrometheus: def testPowersOf(self): """Tests utils.PowersOf.""" assert [0, 1, 2, 4, 8] == PowersOf(2, 4) assert [0, 3, 9, 27, 81, 243] == PowersOf(3, 5, lower=1) assert [1, 2, 4, 8] == PowersOf(2, 4, include_zero=False) assert [4, 8, 16, 32, 64, 128] == PowersOf(2, 6, lower=2, include_zero=False) django-prometheus-2.3.1/django_prometheus/tests/test_exports.py000066400000000000000000000021221442426466500251560ustar00rootroot00000000000000#!/usr/bin/env python import socket from unittest.mock import ANY, MagicMock, call, patch from django_prometheus.exports import SetupPrometheusEndpointOnPortRange @patch("django_prometheus.exports.HTTPServer") def test_port_range_available(httpserver_mock): """Test port range setup with an available port.""" httpserver_mock.side_effect = [socket.error, MagicMock()] port_range = [8000, 8001] port_chosen = SetupPrometheusEndpointOnPortRange(port_range) assert port_chosen in port_range expected_calls = [call(("", 8000), ANY), call(("", 8001), ANY)] assert httpserver_mock.mock_calls == expected_calls @patch("django_prometheus.exports.HTTPServer") def test_port_range_unavailable(httpserver_mock): """Test port range setup with no available ports.""" httpserver_mock.side_effect = [socket.error, socket.error] port_range = [8000, 8001] port_chosen = SetupPrometheusEndpointOnPortRange(port_range) expected_calls = [call(("", 8000), ANY), call(("", 8001), ANY)] assert httpserver_mock.mock_calls == expected_calls assert port_chosen is None django-prometheus-2.3.1/django_prometheus/tests/test_testutils.py000066400000000000000000000111261442426466500255160ustar00rootroot00000000000000#!/usr/bin/env python from operator import itemgetter import prometheus_client import pytest from django_prometheus.testutils import ( assert_metric_diff, assert_metric_equal, assert_metric_no_diff, assert_metric_not_equal, get_metric, get_metric_from_frozen_registry, get_metrics_vector, save_registry, ) class TestPrometheusTestCaseMixin: @pytest.fixture() def registry(self): return prometheus_client.CollectorRegistry() @pytest.fixture(autouse=True) def some_gauge(self, registry): some_gauge = prometheus_client.Gauge("some_gauge", "Some gauge.", registry=registry) some_gauge.set(42) return some_gauge @pytest.fixture(autouse=True) def some_labelled_gauge(self, registry): some_labelled_gauge = prometheus_client.Gauge( "some_labelled_gauge", "Some labelled gauge.", ["labelred", "labelblue"], registry=registry, ) some_labelled_gauge.labels("pink", "indigo").set(1) some_labelled_gauge.labels("pink", "royal").set(2) some_labelled_gauge.labels("carmin", "indigo").set(3) some_labelled_gauge.labels("carmin", "royal").set(4) return some_labelled_gauge def test_get_metric(self, registry): """Tests get_metric.""" assert 42 == get_metric("some_gauge", registry=registry) assert 1 == get_metric( "some_labelled_gauge", registry=registry, labelred="pink", labelblue="indigo", ) def test_get_metrics_vector(self, registry): """Tests get_metrics_vector.""" vector = get_metrics_vector("some_nonexistent_gauge", registry=registry) assert [] == vector vector = get_metrics_vector("some_gauge", registry=registry) assert [({}, 42)] == vector vector = get_metrics_vector("some_labelled_gauge", registry=registry) assert sorted( [ ({"labelred": "pink", "labelblue": "indigo"}, 1), ({"labelred": "pink", "labelblue": "royal"}, 2), ({"labelred": "carmin", "labelblue": "indigo"}, 3), ({"labelred": "carmin", "labelblue": "royal"}, 4), ], key=itemgetter(1), ) == sorted(vector, key=itemgetter(1)) def test_assert_metric_equal(self, registry): """Tests assert_metric_equal.""" # First we test that a scalar metric can be tested. assert_metric_equal(42, "some_gauge", registry=registry) assert_metric_not_equal(43, "some_gauge", registry=registry) # Here we test that assert_metric_equal fails on nonexistent gauges. assert_metric_not_equal(42, "some_nonexistent_gauge", registry=registry) # Here we test that labelled metrics can be tested. assert_metric_equal( 1, "some_labelled_gauge", registry=registry, labelred="pink", labelblue="indigo", ) assert_metric_not_equal( 1, "some_labelled_gauge", registry=registry, labelred="tomato", labelblue="sky", ) def test_registry_saving(self, registry, some_gauge, some_labelled_gauge): """Tests save_registry and frozen registries operations.""" frozen_registry = save_registry(registry=registry) # Test that we can manipulate a frozen scalar metric. assert 42 == get_metric_from_frozen_registry("some_gauge", frozen_registry) some_gauge.set(99) assert 42 == get_metric_from_frozen_registry("some_gauge", frozen_registry) assert_metric_diff(frozen_registry, 99 - 42, "some_gauge", registry=registry) assert_metric_no_diff(frozen_registry, 1, "some_gauge", registry=registry) # Now test the same thing with a labelled metric. assert 1 == get_metric_from_frozen_registry( "some_labelled_gauge", frozen_registry, labelred="pink", labelblue="indigo" ) some_labelled_gauge.labels("pink", "indigo").set(5) assert 1 == get_metric_from_frozen_registry( "some_labelled_gauge", frozen_registry, labelred="pink", labelblue="indigo" ) assert_metric_diff( frozen_registry, 5 - 1, "some_labelled_gauge", registry=registry, labelred="pink", labelblue="indigo", ) assert_metric_no_diff( frozen_registry, 1, "some_labelled_gauge", registry=registry, labelred="pink", labelblue="indigo", ) django-prometheus-2.3.1/django_prometheus/testutils.py000066400000000000000000000145351442426466500233240ustar00rootroot00000000000000import copy from prometheus_client import REGISTRY METRIC_EQUALS_ERR_EXPLANATION = """ %s%s = %s, expected %s. The values for %s are: %s""" METRIC_DIFF_ERR_EXPLANATION = """ %s%s changed by %f, expected %f. Value before: %s Value after: %s """ METRIC_COMPARE_ERR_EXPLANATION = """ The change in value of %s%s didn't match the predicate. Value before: %s Value after: %s """ METRIC_DIFF_ERR_NONE_EXPLANATION = """ %s%s was None after. Value before: %s Value after: %s """ """A collection of utilities that make it easier to write test cases that interact with metrics. """ def assert_metric_equal(expected_value, metric_name, registry=REGISTRY, **labels): """Asserts that metric_name{**labels} == expected_value.""" value = get_metric(metric_name, registry=registry, **labels) assert_err = METRIC_EQUALS_ERR_EXPLANATION % ( metric_name, format_labels(labels), value, expected_value, metric_name, format_vector(get_metrics_vector(metric_name)), ) assert expected_value == value, assert_err def assert_metric_diff(frozen_registry, expected_diff, metric_name, registry=REGISTRY, **labels): """Asserts that metric_name{**labels} changed by expected_diff between the frozen registry and now. A frozen registry can be obtained by calling save_registry, typically at the beginning of a test case. """ saved_value = get_metric_from_frozen_registry(metric_name, frozen_registry, **labels) current_value = get_metric(metric_name, registry=registry, **labels) assert current_value is not None, METRIC_DIFF_ERR_NONE_EXPLANATION % ( metric_name, format_labels(labels), saved_value, current_value, ) diff = current_value - (saved_value or 0.0) assert_err = METRIC_DIFF_ERR_EXPLANATION % ( metric_name, format_labels(labels), diff, expected_diff, saved_value, current_value, ) assert expected_diff == diff, assert_err def assert_metric_no_diff(frozen_registry, expected_diff, metric_name, registry=REGISTRY, **labels): """Asserts that metric_name{**labels} isn't changed by expected_diff between the frozen registry and now. A frozen registry can be obtained by calling save_registry, typically at the beginning of a test case. """ saved_value = get_metric_from_frozen_registry(metric_name, frozen_registry, **labels) current_value = get_metric(metric_name, registry=registry, **labels) assert current_value is not None, METRIC_DIFF_ERR_NONE_EXPLANATION % ( metric_name, format_labels(labels), saved_value, current_value, ) diff = current_value - (saved_value or 0.0) assert_err = METRIC_DIFF_ERR_EXPLANATION % ( metric_name, format_labels(labels), diff, expected_diff, saved_value, current_value, ) assert expected_diff != diff, assert_err def assert_metric_not_equal(expected_value, metric_name, registry=REGISTRY, **labels): """Asserts that metric_name{**labels} == expected_value.""" value = get_metric(metric_name, registry=registry, **labels) assert_err = METRIC_EQUALS_ERR_EXPLANATION % ( metric_name, format_labels(labels), value, expected_value, metric_name, format_vector(get_metrics_vector(metric_name)), ) assert expected_value != value, assert_err def assert_metric_compare(frozen_registry, predicate, metric_name, registry=REGISTRY, **labels): """Asserts that metric_name{**labels} changed according to a provided predicate function between the frozen registry and now. A frozen registry can be obtained by calling save_registry, typically at the beginning of a test case. """ saved_value = get_metric_from_frozen_registry(metric_name, frozen_registry, **labels) current_value = get_metric(metric_name, registry=registry, **labels) assert current_value is not None, METRIC_DIFF_ERR_NONE_EXPLANATION % ( metric_name, format_labels(labels), saved_value, current_value, ) assert predicate(saved_value, current_value) is True, METRIC_COMPARE_ERR_EXPLANATION % ( metric_name, format_labels(labels), saved_value, current_value, ) def save_registry(registry=REGISTRY): """Freezes a registry. This lets a user test changes to a metric instead of testing the absolute value. A typical use case looks like: registry = save_registry() doStuff() assert_metric_diff(registry, 1, 'stuff_done_total') """ return copy.deepcopy(list(registry.collect())) def get_metric(metric_name, registry=REGISTRY, **labels): """Gets a single metric.""" return get_metric_from_frozen_registry(metric_name, registry.collect(), **labels) def get_metrics_vector(metric_name, registry=REGISTRY): """Returns the values for all labels of a given metric. The result is returned as a list of (labels, value) tuples, where `labels` is a dict. This is quite a hack since it relies on the internal representation of the prometheus_client, and it should probably be provided as a function there instead. """ return get_metric_vector_from_frozen_registry(metric_name, registry.collect()) def get_metric_vector_from_frozen_registry(metric_name, frozen_registry): """Like get_metrics_vector, but from a frozen registry.""" output = [] for metric in frozen_registry: for sample in metric.samples: if sample[0] == metric_name: output.append((sample[1], sample[2])) return output def get_metric_from_frozen_registry(metric_name, frozen_registry, **labels): """Gets a single metric from a frozen registry.""" for metric in frozen_registry: for sample in metric.samples: if sample[0] == metric_name and sample[1] == labels: return sample[2] def format_labels(labels): """Format a set of labels to Prometheus representation. In: {'method': 'GET', 'port': '80'} Out: '{method="GET",port="80"}' """ return "{%s}" % ",".join([f'{k}="{v}"' for k, v in labels.items()]) def format_vector(vector): """Formats a list of (labels, value) where labels is a dict into a human-readable representation. """ return "\n".join([f"{format_labels(labels)} = {value}" for labels, value in vector]) django-prometheus-2.3.1/django_prometheus/urls.py000066400000000000000000000002431442426466500222400ustar00rootroot00000000000000from django.urls import path from django_prometheus import exports urlpatterns = [path("metrics", exports.ExportToDjangoView, name="prometheus-django-metrics")] django-prometheus-2.3.1/django_prometheus/utils.py000066400000000000000000000015201442426466500224120ustar00rootroot00000000000000from timeit import default_timer def Time(): """Returns some representation of the current time. This wrapper is meant to take advantage of a higher time resolution when available. Thus, its return value should be treated as an opaque object. It can be compared to the current time with TimeSince(). """ return default_timer() def TimeSince(t): """Compares a value returned by Time() to the current time. Returns: the time since t, in fractional seconds. """ return default_timer() - t def PowersOf(logbase, count, lower=0, include_zero=True): """Returns a list of count powers of logbase (from logbase**lower).""" if not include_zero: return [logbase**i for i in range(lower, count + lower)] else: return [0] + [logbase**i for i in range(lower, count + lower)] django-prometheus-2.3.1/documentation/000077500000000000000000000000001442426466500200365ustar00rootroot00000000000000django-prometheus-2.3.1/documentation/exports.md000066400000000000000000000117231442426466500220700ustar00rootroot00000000000000# Exports ## Default: exporting /metrics as a Django view /metrics can be exported as a Django view very easily. Simply include('django_prometheus.urls') with no prefix like so: ```python urlpatterns = [ ... path('', include('django_prometheus.urls')), ] ``` This will reserve the /metrics path on your server. This may be a problem for you, so you can use a prefix. For instance, the following will export the metrics at `/monitoring/metrics` instead. You will need to configure Prometheus to use that path instead of the default. ```python urlpatterns = [ ... path('monitoring/', include('django_prometheus.urls')), ] ``` ## Exporting /metrics in a dedicated thread To ensure that issues in your Django app do not affect the monitoring, it is recommended to export /metrics in an HTTPServer running in a daemon thread. This will prevent that problems such as thread starvation or low-level bugs in Django do not affect the export of your metrics, which may be more needed than ever if these problems occur. It can be enabled by adding the following line in your `settings.py`: ```python PROMETHEUS_METRICS_EXPORT_PORT = 8001 PROMETHEUS_METRICS_EXPORT_ADDRESS = '' # all addresses ``` However, by default this mechanism is disabled, because it is not compatible with Django's autoreloader. The autoreloader is the feature that allows you to edit your code and see the changes immediately. This works by forking multiple processes of Django, which would compete for the port. As such, this code will assert-fail if the autoreloader is active. You can run Django without the autoreloader by passing `-noreload` to `manage.py`. If you decide to enable the thread-based exporter in production, you may wish to modify your manage.py to ensure that this option is always active: ```python execute_from_command_line(sys.argv + ['--noreload']) ``` ## Exporting /metrics in a WSGI application with multiple processes per process If you're using WSGI (e.g. with uwsgi or with gunicorn) and multiple Django processes, using either option above won't work, as requests using the Django view would just go to an inconsistent backend each time, and exporting on a single port doesn't work. The following settings can be used instead: ```python PROMETHEUS_METRICS_EXPORT_PORT_RANGE = range(8001, 8050) ``` This will make Django-Prometheus try to export /metrics on port 8001. If this fails (i.e. the port is in use), it will try 8002, then 8003, etc. You can then configure Prometheus to collect metrics on as many targets as you have workers, using each port separately. This approach requires the application to be loaded into each child process. uWSGI and Gunicorn typically load the application into the master process before forking the child processes. Set the [lazy-apps option](https://uwsgi-docs.readthedocs.io/en/latest/Options.html#lazy-apps) to `true` (uWSGI) or the [preload-app option](https://docs.gunicorn.org/en/stable/settings.html#preload-app) to `false` (Gunicorn) to change this behaviour. ## Exporting /metrics in a WSGI application with multiple processes globally In some WSGI applications, workers are short lived (less than a minute), so some are never scraped by prometheus by default. Prometheus client already provides a nice system to aggregate them using the env variable: `PROMETHEUS_MULTIPROC_DIR` which will configure the directory where metrics will be stored as files per process. Configuration in uwsgi would look like: ```ini env = PROMETHEUS_MULTIPROC_DIR=/path/to/django_metrics ``` You can also set this environment variable elsewhere such as in a kubernetes manifest. Setting this will create four files (one for counters, one for summaries, ...etc) for each pid used. In uwsgi, the number of different pids used can be quite large (the pid change every time a worker respawn). To prevent having thousand of files created, it's possible to create file using worker ids rather than pids. You can change the function used for identifying the process to use the uwsgi worker_id. Modify this in settings before any metrics are created: ```python try: import prometheus_client import uwsgi prometheus_client.values.ValueClass = prometheus_client.values.MultiProcessValue( process_identifier=uwsgi.worker_id) except ImportError: pass # not running in uwsgi ``` Note that this code uses internal interfaces of prometheus_client. The underlying implementation may change. The number of resulting files will be: number of processes * 4 (counter, histogram, gauge, summary) Be aware that by default this will generate a large amount of file descriptors: Each worker will keep 3 file descriptors for each files it created. Since these files will be written often, you should consider mounting this directory as a `tmpfs` or using a subdir of an existing one such as `/run/` or `/var/run/`. If uwsgi is not using lazy-apps (lazy-apps = true), there will be a file descriptors leak (tens to hundreds of fds on a single file) due to the way uwsgi forks processes to create workers. django-prometheus-2.3.1/examples/000077500000000000000000000000001442426466500170035ustar00rootroot00000000000000django-prometheus-2.3.1/examples/django-promdash.png000066400000000000000000004554271442426466500226070ustar00rootroot00000000000000PNG  IHDR/b\ sBITOtEXtSoftwaregnome-screenshot> IDATxX?׾`IQDKHՆ!^AZq\#%3~FU\l  |Zc О%+P\+4a%0$|E?$wrϼ`d290>"b5wdە-=q+ȝ;9#g},{xv#KF^+V?~eax=yGW?o}fܖz4*j˻Eo}JblPwUۃdOD{L,֡97 w]5)LoiM<]gn+N[į>N/;:ZNkU!Ҩ /8YB7\!w<9q٧W8s۷o߾}gkGߋqv+VO~TS-W6=qpV#6ɿ'>u㓶gy×>.;|hwɓ]BvF>#yYdIO4^4l6ֶ;]Q/ {UHԳy /RQ ?ǝF)>gE P75yuGU_[zkOjJLpϰLDD翹l~$內<@܃9Ws !Ӡ#'JoeGVs+ɓ_zJ {o!O8O31vُOԨ #oWAgb$r#"?ITDɓگ. V?yK#dR'_]q_x骉B ˯.Ŗ+v;6}VDW|PoXXKxՕO\}ϗ{Ȋ^V7?72?"[ՖCڞ￿~ɏO}|ԱQs^Z{-8?9Z7?_F<_{ݻ_e=RK?ovNmݻwN9n-2<2=U£'M:'|xz(jwWG4Ϟ#5aNrU5m/s>"OSO~s|Oj?>mpӝ/)/;ygϞ|pk|ZAl'vziR#u΢~Ez/G32882ӥۋJ8vWc_=㿽HX3FGƾq?ĉ!OTб/622}ROh etD""7nY~"ё[ 㷳}:؂н:&;_>觃[6:`24JDCϲ/}|2%g㺻Hhk8qٳ}|o窧#ϚFُdžN(C=o_ŝ'jnM?pMH O-DV,>xR8kyNmd>D{ǡ-g qX˱7n暎6BD?ϟ;8 L(NjM l׆<<<ӂW?[P\iFE>~:: AC>O5"/"ӯ>3GRNo gWw]kRz)%p䯊؈δn mH_uN{D+W~߲oH_6m'l!"J_wvt.Ljx9M)L@DIgVn&Ө=0zFR{eQ":D4rd}e۪6ن;wVuyY:bN uihr]=rsMwCA"?XIF7!"D,ڵ;ۻ#̐]g:"mx]rX*}n˖-2Y΢-e׿X=o9'Z}I7CD4:tr4虲UHȥ4M#Q`"_nF||iiDO##CFh |>O|ﭧa$,K Ku|7[7#asھ ߵ)n]~ހΣg? ~^x3Ņ>/zo#ϏwUf\ɟ ;fC?kI'xbO~E˶Ѿ/[̜ _Xj#+, LWgMg>b?'7F^y+Y*$6)?)7<|Ȫ垞?5rk<|~Y.Tw~)EW_<1|飓ݣCҟmDnnn<<A}K~Fm~awFp.5W?%?{ds[SOhR-?{[5[_ݫ#"vȠk# *TUAi["koMt3[-'Rfo" _]ZZVih76h8)lgވ[GwRÇOo!"{h}*pUlAWFMU~' y*J͗QqFDU'yibF]5_`E<"*͉ '֞"i+'?P{<E4b=Q &3gիiCǶu??ۊ{۔UyYi<6<}ybgF&w[WBSe/Heʨ7ߢu6ԷM%!܉Fڿ7I!T ٟ Z㐣8)o)  }y/wJLYn]=g:R3fuMj*m, oVЎLYmsJ%Ķty9:47BQO]F\1Mڂ;vs?3DD6_>ll$ [xԣX-Ƌ9T]0[((u<4kL:G ^PZeD#"6?<2-hCycn>TY(A&ZRM8XG,T s,V/屈w$[=6\l,9ÎNAkjjll,_ogX)l<ZMfؐAb7/۲fmJTo "2zl2x1jO4ۼ=Q6:&<`'3f9˞lUfXOț9zyBԌgV0˶D!}Zg0e)^Ύ.ɯ:Pk옻HDn}ll -dn/,x׏Mt7k3qmœbxB@ QPݯNشv5{`6lmth2ιʠZׯ9˄3Ve<9IѢiYn z3j5qyDDDfYL=r|g-츚'6:c Jd<;̷.\1Uyz]49eڴYaZ O_ѝZBf<[GiVX&OH>0= K3d3506]؂0GsNxTfUUیohrTG܍x}Ċx,ρs8:rkn?a ;KMQnRNqlk)M+Ϥ Jq?0g0,~SN*ImL,֍uQ40rl񞊜rd1&cz Y18FH.i~}ebOL$mK=5Ɋ,eMו*;ߑZ<s5抹&qc)?Tp]+Q[qT) },clkl2soiҙjx}bU'YuɛJ}DB1tmRXMyCim hi }B=mI5}%wjnA\Ֆ41ij***j4 ڼ I{h渢Ac0=m&GㄥE:dM((޷Y8iIRR'E̙y;EhYt^f`(JTZeyIiaxț-fi"-5Z8L[U4 fXD/PXs&CgYERѵE'P#K0a̭ܸqe5&Gզ?-ꄍׅFl@3>$&v~3i\@ gfh2琢`n-g1=sf]sA0Fm}afm| LuEu1_Xb BZ1Wfa3jNW,v~`(e ^W4g:wil2w8c$ 2Sc'M <.f1A)b2yׯRl5L^V]X$ mFUnzs5 ex6cRN&KJO.dЪɓ/ G2bhV 0f̝ku d<ׁ:\2@px6ts[>",r9<N9W"EG, z ީ]s3-);{gڵ?ƍ}hX.\0eŸwbo-!>,0s7aS)tzIn Ag<<<.>,)㹹?H522"<0ay0Ø+Axhu pZ=p<Cpxoqv!I,R:P2F2&?iRKƟvf<2U)vYʸGNԯk7)u=&hcApb sd|%l,0`%g(O IDATN?9W'H?V},/V+c6= GDLNɌ/e:uM -!"k}"]=z+qRR' {k*2.DfaLM%3޵3\%dB;ac(/eNjnrҳE>ۯ[GQjV~ [6'5O{j*b ÂTyҡ2&夆DYSiYJ5LY @.l+ZȮbs/ we{R(l^tNYABBQev$#"%W$s8ٕE Erbx}giMxQgB16& ].Kd y%VM#Yڄie9 iX.K-*OvV-""16n~7Cs}ɏ_:ekWRum/`|%a֭^9内acnB muMvf6*GiWE+dQuKJKK򓖾Ϯcw˵*̆&́DiBx+ƤoZݢfaiϯJ q/ds|B7ڛIM'mIbPPȢ+OKBBBB$tGv%,yxH|oΐZKcD~SH@RYu\06SD9/H,Dd3*-8Cʷ;Aiy "5:- j|l" J* &zʲB߾b|`35ZIVyH. cyr\r\rBRCbc8E%eeb.5ъ3KiTʊc 53U:]ccL &yޡ0Xco,M.9dWf)BH&""69\Wh*SK͒cյr^^i*H8151:+D75Ld{ \"wuhMdx\,F+*=_/Gi۞""'Lѳ1I,D osbEfs}DBl_I| ̒CyѼ)ܦ= yƎ~/x͇I% dϪ',ڷG5775) v:#^V][] 'rGzH L-ޢ*%#/_$̉~Psg먪0 R%~$='+8BB. {NjJo!G WM̦ϒnږ D 0ĚxX,61f| ;޴l^:6ƀm2::Șz/3ed!B2il D_&1iAI|bz37O*OD-^\q|eT-"RG 龓آluCYW+9[gXL3X,6-HH RHX )d)dD;$&&Ɖ-QʵY{jztD2iuDDy_A$z=#M>ͤ7*_IR]SIܣ(uIb LiҘ6EHSDcVYQXkR!ٍz¤$SțLtEn=EfW9L,ǢB4_]uUM +L.e[wLO"1/1Cc%axy,7W.KL]u~-k[- HI"FhRkFyfc npHYOZE=i1'^#"6tf#"*5.W' D+vsvi&a!s4ǖN%4jTV[WU]]"svYo[Mn8|"'#\ٶK~5Y%)SqdyյAB.^fl2l&q)%YIԾetg<""oqz U&WY $a2,Ϣ),T,DD5,pXOMŅm4s,]UU)ڲҘ%#X, ,6?,em:X%^YXIYߞzt](e2Yvvbxv&1h*+47PN6s]ζr4 Vv|dY^:&T(/OݖS7o l6cp I"oKcbWM )tܻå) CuKX^yQNB. b'uD.S)F_D@DlG&p S(% !S_|S`;SISĊ))+JU$Aە8=;:,3i &pyjNRUF:fcLkH`j ~kh(t:d w-N(ΎlG$\];h>{:.\8עW`bwg箇<*I6`qBh14Lf1MLwg3i wi;2cgu8)g(TwL&~t5=x{d{*RsTzC[1$PSU4 jQꌦʜzM\qB >7G3vh Y,"'_7U3K8u 12l<"DD/O|; yfMy"+ c?xK *<-a)ddlѤw,O(*+I,V$f)ZZYesy,ب3Y,¬{7=** h L]VVh26ɪkc t%~L"1Rݨiy  9?,m[b+ss4VY`)o0v 3tkf(֦*vZ̍M~EtV?=\oAK~|֫,~Kpgbl,Rv"8H0b^/his#Y=뷂;OiL3ᾱy*͋ejk;\v:+9WɐC%;<[^V-V?2(gV<ڷyQ %01J,_q|d% JJ2ssxA1ٕRo{IJYqV(DH)4g%q#r$M(7<$, RJ)@$̬{ _819Se9:YruM [^rȒ.c%^l,GD ˕ " #wJOsӳ7ggW,O)/$?L.|L|YA%?49/h[;PRK xAљeYzZxGd7'o <}_tNn[f 쀔}]ۊ7,>ߠbf.t_aO~ֶ*X}%I|=׍{r{rYa࠰h1 3[p>ֿ,<|X~N_kk++<|ƍok3 j}n_|w*%27,XooSbcmorp[ S#V'M63AE{áamw{?o{mfqL9?C=?Nr܉zŸw‡p9y[-}DNU^ERz w7npv`n&@pxu d<ׁ:\2@pxu d<ׁ:\2pkgqEh=zs[91 /X u/ƍs[91 /X xuuƻq4E /X"z<ׁ:\2@pxu d<ׁ:\2@p:0{Fy=UC{u%>^_NDD3i/fNZyɲ;_ԏED4Т|HUCU_yY3eތK~T씧IH[ʱr9'< 3YfM<}~g}M`PwM)[H kXϚyc) .9xLw G}iɒ%t]nzԽ7dٲe˖y]ڮٟv CD]Uijۯ^\lvߩz?Мbsot]q^l8Jl>w176EEE& 67cR&GKvʾ *޹`"***^i\P))6***j"S sM;c%"iQk3u.^@XX+x0Dz;%.*****.O񪱁/IK;>򅕘 oEEE%/@SM+KMQcW/+^$5?S%l9o!메}D$Y)gGu0J?btjO&u슧U%EEE 2Vm VnذZn0] n"Y"@"j?g)?̬ }6tjuJ:D(]7W}ɬ]gTCD~%-ok ذiOkY*-*-[Wxw<߯iuћ7GsZkhY 3\ h}Ɇ5W/j=5 !""U,U.݆nV`DD0xfڤvӺL%C5:{Sjگ. aul2>us/@"gWQONsb8o8+9DW_e3ŷc:tGER"k/]koio9?zxJP*i Ǜ6ȃiokԭ~sgUlݸڠ&Z%]_TEuŁ;X̹Nnᴶڛ\hb^l#Z# F+O[O<\+zOspyوhYta}x!45ל Cz/ `Y06/X74\5#x5;X*\P%~],ץ>cXX{fjuW(/R+ dztV;}2mۻjCׁ d4ׅ/5ǏC,Gةs}Z+nh#Z cm!ַ.X}n7^ %MM\qDY/jJD?t׏ *{w>" \ND/woxQ둚n[ \IDn:?{S KW]Դ_'uk.jۯӲ \kׂ]< [IDWu֭W[X+]?wCDsJ<""{#xLSP]|@yɏ c]ˌeYG4:j'EqwܸnZf֮[ny{YZCD]ZDdmSϢbwj2wiN"M)sSI7L~q%o3( '% mOONDKwlnTBk7g/ۙae3[+?bϝ Iiֆ=m >tgCrkjHA+ Y)}}*xfj['4%WX?{u>qdݍMH 4ZDlKN/qP/΀+$Dr%nN /K̽!܁I-^rzA`(*v?IQC{γ<Ϟk}}/cz8[ḶO\2+O^]]}e/ /+ty;o۴0IS4c00\}מL?s (g>}b_J7x= S//~;vz5@OUfz xi2#;y>S~叻xk0Ǟ|/O3֬׎kx5 {S_{Md<_V72|.m{į~#W^}7U)D'O=iao|Oq>pO=W_c|ߩ|p_};9|y9nд}OXyqa?3_ƟuxzfGOѕo_5᧌W|=cDd߸۸OYʕGԗ~Eo?qp5ҥ7W1<4Y$;_~S;\~w߽vmߙN;g}MSr;J9ԗfOj!}x}u^)Y}_o;_{#mO@ @uܕk5ӻru5˫/_f):@ NO啗g/!B=е@ &@ 4jޝg@ a7 s<@ @;9@ @ @ @ dG @ {2#@ @ ށ@ a@x@ w s<@ @;9@ @ vf'$|ϻy '|OUwkתd2aG*H$VM#rDV߿xW}ൃƹ/pӈ#rD-s@e@ [fxxDwZD9"w&{8$x9"Gܽ) #ށpAxD^"rD]6!9"GMV+yߔ/BzoN@xpx?}Zuԃ_>LG#ž]_ps%U47? 4W={iD9"wǸy7/ܷ}'F!?T% d7x[bYM6ue8=/N,_ b,:NNS8'M[T8XLi&wm)TK/.6m5ͩC[Ϧq}p*|2܎`s8#o_rirˑS1YY ŠCR=d2 sbmɩg|v"QuDGBk1FnкFcb9r?/%n4enNΒOF'gӠhWKV㢑D"x;g[JOk1G6ONI۫xo.n3xe$x pz_7o麛>7DI`AI+nj#FSbp"(N&U51A?kYdp>&&?hl]j,r!fr 6j[_ 3 #_ x959`0,f0`9r ='~Lw\+v{˟|֛>xh_Ze/0EFu8{%{I49Ojx¹ Km8Vޜm>e0pT@wJX-JV M~qbtx45@.&]n aeI<>7n},KS ˲tni43&V:o6?v4Cь.\-z~-E#V ϲE1O5s{㝅eywwV^y$xZ˭tTkp:6'w t]AK-C#敳%BYwX`t51u&rU!6Jg݌?<pUk"kց蠳Ihy zgmH3?62::^,O3CS|9f.'r71w} %V\~ϼ ('޿.Ֆ<焑a*E"RX;y4H.{Ԋ~yjx쒪d1 x R$CPO<y:sH fZHtEA=^OW3/D'Z!_{NxؒѢ/0wssSW8s6iݴL%~ܭ~)pnNK̄+BP&Oˮ@ iLek;u2O>x/bC_X‹BE=^ X>'>^uPDm.(7'-P2M$+ׁ1xH N]_=25#,ψϱ47ܚ:SXA|K- BWbFPCgСL7 K7-\!N5xhUEoRٷs˾~~%|vIJ=jeSfPC8ی+ؼ9րFoegLoAyj*kEzى3kJs{˳|() A cĒ1E~IL4hjxР(ɿOjapzz=b=}sap҉׾"Fv:9\D?r!3;~<䳮H$VK*jFa5n_-G" @P;Nt<*A(_Py׊RսϨD&I_$<~[/E˹ΆH(C}=DdN |J>#6Wڤb#0E÷B*t)1ؠ935OS]ȳSW~u7kZ#\R:\4px^lٹM?sTOGd;ЧO?ɯQoo:@|AT9 lx"el~FNZj>4 !(GledJ8g8[ؔ?mVz Pk"MNX=j;ml+?y `f/`bp^FcYpO{.@4ko;v4ݝ}:@{C}џw>UIgb5X@,tN NΥ,^׬'Ԭfv{MHK1Y3ƔbpRt.## .OEπDH,>NϮjxܟ?"}/3S gEl40GMĸv$0jTd+h8&<(#nV:9zwSb4a&k3 YNgfup`Qff5gFQID>YXW*uL)^{i.vr+yrV.2#U VY-9%Ɣ#n\0Gut5gƔXNɚ^ $ߎ&hh۠&EHۼ>09.:B3-8 [r` ?[-* +) +fKvZ;jT"QRakJK =`~Y#Kvzynvg[^[*V9f[_ |J g%τrbIbJcfZǮ.G4ܥD,'gsskZ\G,-C;`cvucT/Uο^1Aڽ>n8##^3%b-|ɡ5cT^C X*?p&FoC` 8jO-Fd\w;5ie=Ĭm٪X\7kcX32Il /UJ%~i%M&5L~)Zhe9/7Bf$/˚ %5Y/1r &b^n}{kxO~{YxepAFokKwi`h1lY`:xY"h榖2\'B4"@4RASrA)`-XB0 zhM:}kɄ`y4>88/gU\h"W2x늵;a((Y5Rxb Ƣy|⛧jjXOfah6 esH8̖[%Wn5E  cZC̤d y7 嚝ܹd(CC@)jrd"p<ޥ @cq9ZQgc ѬZB<X ə6Y(76PCPWecS5?;5py ç$$Xk4 ,kl.ȑq'qn45Jy9NEkxއ ;9@Gɖ @V G8?oXﯵ'@{ܡBJgzJ|-zx^OB4` RBBy ʭ9Tނ!^^U)W~ w.un~d=. C4 bd5cHq-QT'Rk rJ*i=jT"#L1cIHjEHUXc{Մ6ϝ%p7佳z k<^;ZEfQF 7p5̀sD^ht5l}`c `3ↃF>9@-i(^LG .z聺 ~]jU)b:bp6Jy"-aByKUV6][n)GaA`MŠYZSjS7"EairtX@uu#f/@\UޤgK}>rl{1 sYe/KF@ET#`x=k4 s+ $qyJ Zb? 8y/^Vā*˘uKUfOp.U`"USJ3@z^<#e5}Cnfaznόl:²s5`' [jXxa%YkvM_kSK^=;o? ޻M=`ͦ$x.|dd%1F:` Мv؊gbxbC}ᮺHt,(ϳP ,]jT$dV/ /*LA3yYsc7ǃɏ|oO;W?t.>곟nُ9gEQGAu@̇B4@p{`8gDQGQQɶx)21ųt @[=+buLG,qy"LuTxVse AA#mv"0,@[=иʗýx㏗Jz1#ͪ|6:9𸨣t%ds5 \H_-OPkF4 ,]ِ̫C)ZEX{X-cdJxZXdad 6N0rCNW::’UWHYj WQILd,M3jp-?90\|hR3*b!KcfTlGώuRBk):(x1;ǂ۱zf<wT^92Ťldvՙg¡i^g3|FL([տm {M N:FXTm2 }-hN\V*yZh&_Zbi8Wl-@)l,׺Ej4=.Q{ V)2.<ҕf~!`8:Huodyl'lH!_h, y<u9\RsKhYU'w,RByۿzn^@=Ҵ\>OWv;(5 v;3=).1ѨYje7{E6{ hG} g&)`7XF " Ɔ9vq2.X-#O Ϡ3Rr%Bc뼝g:MշdLJ!oo6 ,G{-S%LjGm7\#8-Iu18$CKΞCGց#lNqXeŨq.unv!~ ^9i"=$'C98=;`g`2p:6\]vw-95[pP \\.:-(] z&JU.6΀_I$.˩|uac'G Wn g.\T<sfҏ+sLt7DI`AICI-x6\'&g'DPMjb&(x28VSbZ՟Z4i|l5nus5P1c'mԶ&Xk  'f*GD C4uQ|vJυSY)UDn9/B_[o[|@}wh@pϗ:/vZ%EX<ݖkZ .,ekj[zs%wЖQaWpKZ-JV M~qbtx45@.&]n aeI<>7n},KS ˲tni43&V:o6?v4Cьf_?|*k=j{{&-n:xusRjVZrw.nR$x>N5r8FG.Ҡ% Ρёx,_ԻLu071u&rU!6Jg݌?<ڥlR@YB$"w$0:5/=F,ò C {=BvjlIvK;µoQwյwDo^m9YqNR$"%N+$"{G(1ᗜ-.: A8,E"9,O#A>A]/(j5DWTt5Jt"E焇-)-|W?77|u_J9z9DjfV r$ EN'ʣln?5g{Ox(U+Jd2Ekcw8Uzv$9rGHc-񽾣Tk7$O#>O W[CC~ 2DnK-y$xrڨJf>ůRY붨>aM~g`y ]G%Gg!=/'ZBrњ㲛t%du{zy|_lĉe(TRӱ =>Krcg4(YX2> 8B7t?Ec1oAZ_5^rqqrK3-<8uW_Q0b%Eg>͖ r"9MP*B@KL']lm`2םZSC먷 7# puCr6p?6~Yoe3lGK͇R@!M [i,#ֺgR1-jXduMN@Tg#<Cm8mTv'oS eu96%\+vwh EG7*W!'!W`LcyVj9)89x_@NJ;P&柔7JP=5!-d SJI)i^,<%^>Y>!1lrw|:=?6epLI =S Ә 7iڑRȲ\>2jɐIdt.k t8=/SgeYp n;ZCϐJg)jLNt J/w Qye3^Ӥ%LW7ӀStcnGM |l%WQ1;d3m@rP+}C̤$|2m;"dƳ@7"by,ƫ:]Z\31ĔM}@K͆"Rw8FTk\Vpj.Zm0}nVKLфut 4@f>f8 X7֙YkFy՜E%;PNEj,fa] YcO34{ɭ9TXaX؎T/XV #ggSqxdS՜Sv S'Lc;-G&kzE:p7`NP_ |J+vkhEINBLzGMk ռfK{{poUs k<^ء>69u =u>/>-ϯjS%55+2j9_p]88]̭kv:@si.k l*osU쀁͎'ShtϿ W9n{Ťjhm̢aȈ׌rXK#_~rhhM|Xh:&3G8(0*E.d)E5(bɁAGC` 8jO-Fd4WN &agY1db1e*g3AoŌLR*&_ŕKJLjrEQGۙRRvr*_4bd|)MɁ`ޖk}{kxO~{YxepAFokKwi`h1lY`:xY"h榖2\'B4"@4RASrA)`-XB0 zh¹f:}kɄ`yd4@!y9*B1\`޸AC4^W @AɪٗKP0 .<Wc,NFWlz2 F U-Ca*߸0mtx  cZC̤d y7 嚝ܹd(CC@)jrd"p<ޥ @cq9ZQgc ѬZB<X ə6Y(76PCPWecS5?;5py ç$$XZ5 ,kl.ȑq'qn45Jy9NEkxއ ;9@Gɖ @V G8?oXﯵ'@{ܡBJgzJ|-zx^OB4` RBBy ʭ9Tނ!^^Ue2@; (Vou<7iZ iύOI#Ɯ^,rB*W:{V^ ۴m\Wn%2EG`3X@{A77yjMCY: `:ϝ;RTnm_}M hqW "[YTX9x!ϊAqQ0K/2fI`= ?bmϱ6Q7DH$X(WJC`Cf/Hn94]XsAkFǃ4o9nKJh##q2,uZsw B* cUX2qx0mtˊ +ky4my4DkHo8{τpf1.Y5 Bل-(W*c'hB]FSk@=i1ə y(/lMLѶ_)$sbBe7Ѭ_Tہs㑾Upo=W.a+%Cyj栨^ y**y`<.<_rL,qy"LuTxVse AA#mv"0,@[=иʗýx㏗Jz1#ͪ|6:9𸨣t%ds5 \H_-OPkF4 ,]ِ̫C)ZEX{X-cdJxZXdad 6N0rCNW::’UWHYj WQILd,M3jp-?90\|hR3*b!KcfTlGώuRBk):(x1;ǂ۱zf<wT^92Ťldvՙg¡i^Oix>#&tB涇ΈAAPȽ&s#],g\@s|.kh<^-`/LM-4h+ MzŖTx[ۭ(5~|aPrbd65r8kbޖeġ??ΛB!} ׽\#n[mvy9ùXHsBc>OMv=,VP5Tsj!o"Jo06fbjK IDATӳg-|v |о; S_`߁}xU}{Qb/)&I \#Z8ta){3HV ۖuu"YvUF؜50:<:uq.-9Pp W3%e/݃}&:[fYbXnD7˺P4h1]Ϻi`਀x6vCCS\d:t" B;N:uؠC,ò C {=BvjlIvKw*;)F3r˯TRڏ_췊(̻ڻr[M]m9YqNR$"%N+$"{G(1ᗜ-.: A8,E"9,O#A>A]/(j5DWTt5;UWI/=''fB PINBg"6Z4HYMHVQd:*uG%l~HMbKIQ*F+5m*Ȳ4`V$'S ?r`j T(7i TTؐF.+֕Y^gڱs5A*DBjl;JVv_dS s`iиfJZԵ[qH&lQ76fmȠ]E*Whk @cEiR8dK0{VJk0PtU#@f :5G' w:VE;H'҉tSy_t]ft<͔e^[gv]lX(y'^+ژSOM޸A:-++qU~ 2 ZmDјwgF,Tj*nwy$(&2k[jp +Ȭ.Տ1PRo(>ZThKr9<(Q站9RmEu|u՞ (pu5ZT*%pLxzzCeAbT~xd8A*l Rm$e1i]qvRSUE‚}<"sc .tR3+:PH=Q[ܣ=DF~wɥ7%'&4竪ʵ% TErþ$>Q|7&h5=ZT*1LJRU]f8.)L[@.BՖ+wBG;7K_}8Txڬ YXFyk[w7X$)k=4x:/_5*7lI^0)|MAA]DƻAڮ)LgF惥gJr7fV+-`4*Ԭ,rmT"p WeWT48M&zX6[~ eTyj WdUՖN$P3aФōk > !Bhu?j7m{kѨ)h~af/Xؖ`YiiM.J_m5a?F3UD2D0ưʪ21FIGK5;^cyl=*67e>5MEm)m1fԆD;޴CD:.tF1"NByV]UivN \S[|0Bnݣm$xԵ7PhqÔYy9lf1 :RۄjM~ڠ=Q#l A!OE#Y%itu1rx@T]WUcR] On $ '#Z3KkdģO"Dv>KǒnMw(7}h~u.tlt4iswLE [8⏗H'҉t"]|ұA!b)xR'?]#~ofhҟ D!BĔhK&O{ c4]3ov袄-KD:.> D1:cGI3f|S",`:۟gx_cܙ0c;M(p 8#aǻq\/ xB͡{H }?0D!B OG.N[_1Kd0Յ<8t܈~u,Y5ŬEe/cm6֖5s;';m^eݍW &>Vi#A`~(fQe@ ejG-Nt1ӍSi]# tΔe綝[2@9[wܱEݲؒ]RnRɆU.|;`1:\*29.G1&F\RS* @LID.)F NuW_}WK Ԙ1BJRJdY*[ښ) #.EKi<4NOŀn SKy*ق Eg?q= ȵYfr~>V56֩vlK'u55:;#X-5eݠ\[T:/+̍U 67j[Ji]Mi;+F6Fm*+>dM: 㙢TtMGL*hU3PSB&k 2dKmr:܊/ M+U&py]Es_{")).x*`Y'|Tu ?ZXZNW` 3}kIԔW! _aN:!jXJ.G$x@)ucESvIammFmUGn?];apUJ sdUtI?URJFZ_+;A"<$d}zZn:umQA:iCIXH"ozKR-{v$PuPOct7)S@"kK4*REqOCQ {Y{`yy~!,ygQ!mص4F``@^DP;n,}cQNx܀a/+5'SLdž񦷪-D:Np:sSzt]X.avCą$ǧc#ED3?د%ov]uWSӦt˷,Hu댚mOV'/)Raֆq*i 0PY`@Ӓ삢t<\g]^Dڏ6iRl))J(}Y-_EYvQ7,Jd*;!'lcIQfo>JrTu JnӵJw~A脁Yj/h^,ɖVݑvZS֒,9Jk ?Φi,=( KNNS-kGɸMM%[G@уn .X*ˎ`T98m ibog?s!499d*job'2RUW L3NǛުhD:} ]/@ SZ0C /YPOtL lOnQ{sC|\ >$!MN%`@;@یNBF(^Jǁ44Ұ?- G-֌\L/,ـSmLpʧX=R.W濔Ӎ&%ֱA/_0WT;VZuu yE3 $m1?m~Oj!A% 'ӝ$ɁĿG ` OMu-hQI2 U`2j8i4h5mnmU+7ťz Apy*Pn˒eJS &gƅkvVTd[g2qS*g.Ak.dl]_VdpŸL&Z!@SntJJHϬDB9ʂ# w0XRXGRLxZnrtB!$ОǞ7j̦Z*מH0P J0h<^FݘI (hR 0y*IUuMF3miV[~ p<& .!RQ!kw7$xhǛtxh"H^;]3N:Z#z/)5"NByV]UivN \S[|0Bnݣm$xԵ7PhqÔYy9lf1 :RۄjM~ڠ=Q#l A!#aK8aKU^0\R *K'X#*7y< DuuU5&e^^E"] D1_q =W&v~`?OUҷ'7^V+G;"D xqW}z%4H'8"t ۃO pE1b`81f[?=D:Nbю'B)]?3P:'oBxEZ\*TڕۃWbx"D!bAt%҉tqKgC5G7t{\ްC$a Mwl Q(OL6txhLwKČNt"Hю'B.׹;BVgFdHiWxE3~4p:7ɠx" :{ng&%,㳒{t5%5wEHx"ٛ߻olz]}koGaIR6t~v< Iۍ޿C= a29.Ёݻww2mL6txh!U~c^oTu7O=m!YO7s~Y~Mנ$q{N8 nN0_ dM: }mzܦդ7T,_,x&4Db . _ʗV~a^`i[?hcKџm~,-DzɁC549G 락}lƓHK!@go54|: xU*vjt 笒*Ykue]F|;J}54  ҹ,ěFf(Bv<6}E"]С9d.ShtOdžb\4pе:W?h}狇i!ɩB:՗_75"q5w>5 CDa=@e条ޠxyG=tu$nkc̼a^zbׁU+^Ӄ^#ҥKcEeSK[.ILd8uOI4^qXtіW ;.nvõ;[k>tlt4{=?Pz۩n潍W~[dޔ$h57OPC߬_!MO# )1O**hFű4ܻ":ހ;xz؁ЇbD$o;MYky'm^a^rvW^99>kgg^׿1B*\o֟5 )D@So֟=rgmXyȝn{:{y>r_Y52NcÆCǣ(7v2e h?ԊMVNDԱ/aߠ^rwg{~^~$q_(ep8cTsx i{9,Că 8{w̌Ų3`Foy'pDg=o~m_ ~c],|C!5.kO -K6pwR a{x 'S-6 }68 7}4Y0L;2A{D .iP|"ENg{Wߺiۨ6:u:[;kNa2ю?0Y¼=hct3LOF<`0qK8v}CP:*x(͉|vB$g'QCÌ!yt}OM*x0+YPC{RVrSw?句Γ ̃ޒn{f66m!Ao 4jV0峓ɓ.fbFK\FzU~^~254{~3v(e_ 笆kw"ӛ x;Sv6 9^6x9X.Y1O1sk0 G\,m!qjTg{Wn+BtҦ]>}!ۃZ3_2*Գ|i8+8'a竌B9zoױ|}ضe㧮%F{:1䉌d<.{|ό z]pPy=U:]2>wLHwB?sgH{tpf޽{r-^ J/*?i#bI WH, LD|6^AO]6++fݗtt|P`۫g{7Kv㧗4IZr@|fzmv̮ {{M[ݚ>N`kaLiı͹i=cq8~F3Y+~߅ٛ>~3_$a6 aSKq~}s%z;w 1CG񍰋yqݏt/$q>y0s8ăO, %HXb޾r\lY:OJCsVX8g̤92K\N:kFisPY?ظ_G/~y+h@ݤEqv|'WuP8bY@unwN#M4pƃ|v58 avƅk$ R~Ȫ䇇N͙^أ(;v gݗwC+}=sfgzЄsS6N O!aGyAV?cʐ6޶(l;eNw\bɊޖ6_~1T\f/i:jvcK A)xlArE4u搏tܷ\yVpL{'$p.%ƁI&#(7I(kdgJTË~Y/݆eOӏW~ N/9P~_[o:kx54$}'MϽ+7]I lА vd]e-@r"qhtkw+6\)uT|d;k՟KH߸xl1B 7nw?螟e(KkJk~?譧xi0f@s][oh1BC6g ˽lNaQIԷO'ɼ/ +~$TmjQʬ?׷ُ':{56TvWA!K_M?o$XE`5c~ Ľ0U>HJ ͖ʶ7_=U|l?3_i w00}\N`t,Y}m_ᷞ9k:s8E/iLA0),| 3~dDŶu6h`,JDĎ1"húJ%CM!n;evG3.agڡfN23>zCw7ki rO>Ue,c;a`KقjtOysd^ |~~P;Bܾ/%Ӌ=ȂȲޏ!c`xO^r)x9I}>[Ifѱ!5Oޯ2>c<ڷmlTv@5=b|0n/z4jllh#Mz)D2cj9rI?ļEtR ''Jot P}uQ,I⌛AS}A?>Tmn-+6?JSpMG3'\%$n\$@ll!FMah,!o࿜#cӊ6N5 qsIC8V,>/Cg~Y~;[LLKX( _n^.;Tߣx08z{W_泓U{ fa_S|xAM]sAtl\VBG#H<ܸHg;d+.X]c?:*#} "~FHg:nyöAX !>q]3 q>ɍj}>yVB;by=BoH^c.I % |ħ?\.k|1e }f 9[' @{!Ϡi <{ =RA׭I х93]z]5:l#}1kG7sgvOL@}7)'ht7VX0W;7{I^y3]lWk_ed fW>?躵lߎ|֥LD1?\AHgsGy i$y.^E}X=nӱSu4?;Ft|&O$vP3Wq#0y~ /Wn˻bN@\vb2ZD1{᜙^]sodֆZCD.W1ߧ###HJht \SfG?̿K)z=<ǧ9i/9022"I=0ߢG, G?XmM5f#ȑ癩\dW>;i=8X =3 Y6~JS =b_wQ7Bfܘ=K*xp١~12>\|.hLZnO[0=,>*3ɍjџ.eN >.HLyaپ(qjqZ"CGwab|n;w');MoT==rC+Wj{<SK;3gΜ1c7 +XA;|L{l}vZP}eg .sma<+ ҁ{l >ᷞ~O| Jo^x(#>v@m}l.i݆l[jZLzl;Kk|S޾ΙTY*^Fo G oTuXW4u)F9s4On}CgAwӴeO26gߋDs~'?~_P, NExIyhЛ\;^e^Ij?g4,W|L@ӗ'i''~ȡ^?;Pt3: w9x?JƺH8wg(onœKxx{Jxr0BfA>,](~ݞOJ@F=Ss\Xk(qG KVr#& tXP#!YÌ>=H5%Fk6CQh~qˠWvqꜣ `)Sw!Dy:}|v,y}Um?|.hƦ8&zOGB06 ,akIsC3FQ(:+1_|r5Fux'01:'w9X^A]1+0<']a463͎?&|mx!fhgP\֐W+kyŠr"^۶F=3===5 Hی}~uƸ!N9" B?P䃮27\]3:^E6wQ!M@Ha*~~k߿;hp{\ZRٌO(,9h;'/X55/78ۤrsg V BY1(O-m GlQs;uċ zvxǖ!(]UT4@36I_^:ip{}Uۃ|z8'wՐC^ܕ_~w۟?b~[gB=d݋}m!NL,}Fv</ ]AadCx[#]t*|(mh͛Q>1ǐfF !<6?X;y&] v 1EzaT"t= }npq.8ۮ 7ǐ> 7]F6;7(cA7E@Q ΓH)j!܎ZW! ٗ~ӞM;v:% =]^meU)"Dxރ[D۠U3@1f/dN?v<wVI}qQCtaj/>AW|TqVcG'{,\un~YkQF ?e~р0ɡ6%̡CAFǐ5'DRюǁ0<7~@[̙ bGcxz rf@IY&Mt݁/N" =~V#ڃW1j,AA+Fe;ϋ~j aM{ƬB~E0X^1 { V+-Jj)"Dx~ȠCqH lh*x~8qϕzD'TaA:߱DNΓOp(r=nSi:>9`$ NFa2zJgzr{\9Dl:c`*}"ڦBDuGǖX2(-Ԧvzj|)}!x O,sPQǓrco-9 jgZZ^&g3!J%mKC]3g۟Zu󌾻x-h`4;_|H_.оn]lD1 BNAa9ǡoi0<<^6@*,+&}B%)(:Bw`mTTPŽ!s;9.`RR]S3n_5odXK#)Z Az~'ؤ]v% ǴZFIՕJ6p4҆i.4UiP+%#-tdJImLxY!x)&";$G9 _[<05`!R% E<υd 5[2t<˞};s'+&Q#uˣOxLI 3=do"Nzvo)Ľd\WsVĚHQp#K֒QRI3I4Ez%r֓ˮOCIՕK$)HOb0aZ MT졕Iӯ˶R+}djfҍ9^k඗lwEoI<~tL/_mK"Yd#/[ʍi_NK;;ҼULkd{/پxb$f[ċ`!ZC_鳶M'GXRJ"i ٻV|e D$'fwU}mYU% q"QkuU CIxrrMqIz<?xSLvv&F[M+Z MZe#xm5S-y?͸;;trNZx (xn =Yӽ)-N
!q', o2[J;4q]RwS;RcUL9u}<x _?28aZ\x"qbkN_ _c$tڈ i~-$ ?;eprKIScqSL6䖺q׉t>h b\'G (IaP -ԕfi] á̹OgK2V?o%w\vCR!?ŴٌSiWEJ5IY9p7H51k85LU n gPMȂgq,;wLtfVDO&kweFBKq5qF*$Y^H˸^tyHK$U=I]ivٕ0ux}Ȁ .iGo87[/Lf ]e%R!Z>Vf2[;f~-<^'_b|b8: ~Wn/^,rZgqR,cy# 'ԇB(( lUWE^=FQAhbR@L{kc"A#X4جBGfgfh~9 #Se&y'K=rIYT%^Ti!SkooϘ(ggle&֊ G.N Z}Ι`J*c=4sp4^&\]3ZbجIM.>aoeZ.K ̌r펋dU=N|CAʞ2}A^ -]/x7`ssO.= dL֖4<pmB#R{JmoVu^/zS'iDdJj|0<袏h ybʐMNM-#毌wÃ`;1%P>(Ot$<ٯM, ] 0摤lx6P/&\RJmr;.{U9.uە_F{JP1.\3hL_{oVO^.˯[/5wn!Rdq|Aqn+ozQ..w mL&F2p8L˻6lyYTu?^=5;L6Xcy=unޣ=r<_=ϽCȘ2I癿}ܻ}kk?O0Sm\g-Ξ#Pӧ>myIӦaZ-sgY^*%y'Fy|OgVfIp*$qN\pIOcUI =ѽɒJ)$ѻ8.|QAjv#'SOS_~۶{,.K\/J䩱#'xe$: f5$ OFG9K-v/ׇ%}Yht>k(/<' nwQAiveLd}O =ƽ{#/S:#˴eeLOxHUmwblI4vy^AfbxG/hT%cvwyȔo}et"S>ch>֮v}92e٥{HMZ["S>bb&|O"+Avrow*x'^(\vn8&3euq3U.S+ȻoMb<ȚgqR)p_ -jAESyLmin>Po(Gxڹ}jAq׳O>3`0n5:C4iT|p{iQ >bIS__xD7Ж>gjtP4hQHhZe&y' }ꓟBCCuC*[0֐*S֭[3)7\>B'<5:CmmRuB9<ŷr)[..5{KRJi$,v'qekܧOqϺ#'oJN{7򊀠M$S2Or-555Ȗ@ii?Zd/4=?:K@<>zn @,5;:/o7Ն^K`Ԭd65SZzEIVo2gLSn)#=Zc|JKKme&웦X8#EŴM5BG `Iq _ 4W $9$Dc|U,MJ rxƉ ={c{Z{cT uhNշx<F8t0䓺qɺV%j\2")l @ T֖+Z?x9<7mʘRBڙ-uku/Ch^[z< &Iw:-gAx W ܲC'SM䥬oxp7r}m% ǷE+ơxWrJP/{I5pH|R^CzzSY}Io4L"v-{zHOÔ{Lz7X͒GLxG<p3ڢ=풔$GL{TJ\V"2{-%zǫo) dٳ88rǽr`'L&M WVz}b~n +JBAoAC~~>[1 -Z'Z@Mfk5a4IKƔGB1,1Y  .6de$)I6;1VG^$[nYAc02]@g0&O۶m˘@d2x=Ғr1zX9"88bO٥SkBj[E j6( `ƛ Z^ޥZTQ[Pq۝ʺ>Ɖ q8G`V"/,h \_mwblÚ*@!,eIieRyG/ ia튤̈́)0/-:VyK@WbIf\]j>5x,Z agQCmPL)fj0˽ɪT\'^(\vM;3r .=?t`[?3씚(%ywj-eweod&8psk~m$6}tIp2IݸdQf~=fPUmN7Ueo}/or3D|BgVg Z&RhZ<$Yh?jjåJr+^>I IG6X=@0Y[Y:kxRFz<`"?ȈKNVNxᲶf*ȷ]4VU@rV,3;ۇ lTscsٌi씻GcNƔ33Qė)Gĝ.Mme\lK `d,1_ȒecggfhɓP?%./\[m!`$_z\l6HWKIVѻ|\<>EsܩAZ 8XT]fqj&w˽ɪ{ɄCA'wnvdCp2)` '0N\ƑW-RdpgΜGv`` Ae;xk߲oS(5-w狍D`|7T 1и'c&V} 2r]2[C1e}}}r]bɇ;z3,I^6;1?L?Ϡ {Bј%ŞwOWmBXh``=6g0 ϋ.@@ƻJkd ;.{U9S%h~~>B׭yخR$74Tgq*v-#n㒝wrSg^$5{W~;?km;N>a2zVD Z&I$Lk||_g-p\=U WQ?-v46֕;kKyy7հyw[;Y ǫfkv tO}go”Pӧ>m+سp$zﭙ{t3&F4p˛cݽy%;Y1U\XJ  1, (w[g-_Zi6n=uf<m [*(ٻ^sjFn:/I+!OغCEqꮙLq5USk\pƎ}ǒNFGƴ` gƏn.#+,8qἑH|7~dI.Ҿ%5>X@[OyǾ^EX6U@3WXgH2ѯ7c> jQjX[  D$fUcJZ_iSClR̻w[!j6t$uOsxWuL&yU?7oN<9?3o\ey*I8k5SO[T>2}Hw{ڴvi>{떻2}pD_U\x S-NŮqy#7ĦwqAK7񈘀+q wֻł+///))AjDUݲژ-ljcIiZsUݲ~Z-[Ye.O#1-s&X^WNd*W)+4dL/_DNXj8̙h|$lhW5i;\R@ZT,w,C^6;6WMS̔z9j4ᙸi4,{Jޡ'=ԺѤ.)>rx]VjhIv䳨ݱp/O*r[5SVe]O}= b׸N? [%qS>1/jrI+8NDǾя~_mnYC+`޺Q۳gϞ~+j&~?g:x- p5̃Z[[RxA fzXW]PPVh{3hA҅:(# ndZ(C13u% Li+&61^9<묅RᲪ(oxp*v;7a⮗|]V!y"g01ԍ&yzϯx?x> PTlׇ>|Ƀ?V3yx_cp)K D):+)$*NnI[iɇ=Up\$ mg<˱ bflZtͬ RKˁ}+bʺ>lj<8rGoa+b%9{}YLPM8 qw RnPgs^rɨ[>{՝w`k0{^\ݥmkc'tR忢4.,8q1T8Jšޅ$[Grɪ%ԉ KHyR81Swv*@n0o⎓Jq7,W_Fz{{$`(rg!Jk y*{/Pz}9DP>`!P4L%9 ^γ-w_2t8\)oI!Ph&zgxu!9O]k_#.p]H:jwiz{{Y ᩎ3]LRm幓(<[bQ8͒ٱߐ(H͢o tt>Lv;';uUa︷;Y>v1}FR8>q*vnj'z½ _x<7Q`*,g/_jr/R-Ri8KɔK I%#j*e(&y'&zVwYp_Sػh$tƔܽc/v%?FBtKI7z=pSF*DO:k+3Ũp0g"iLzm ^ʌsL.ώXPr;jʺ>lj<aa\<O$A9 П}>y(YXg'`@p̕4c$!17& NfLd/uAc͘ Nfb}) @g)D' NVqK/$ $DZ^Kb+5"war~){zA/;䌾s4+-X@dzRښelݒNš)ry]0??C8K}>A[#&IѤn1W8?T_KsG-#Olq喙fiڷL_|wǥ4ݶp.?>n[w;ڹZ=io^-NZ.0IR'6 o#;Ezo( mS2gkAjz',Vw=zi D0´r Ed ȩLa2 `V6 2E IU☔u}</NŮaa*sͮ&ݾiGwh>{w8q:nooU#{OqbWod .]sa7~zQS* Z(9Mx*WBHahƌGi9k` )08>b{+LL":8BqbNKyO>|?E[1)Ro[K xEz?ڻ™Z{qǤ{aae'(gknXv}{qn7m@MվNR{?Np|LaiYI|1ϴrIIk%?aqhNsxJ\FyR8188N-@|1*e0,0t ru f+M'SaiYI?1ϴrII+-.1L+xpr*x'xqqك{W(IQ$Yd)wONkbsynEgǦbvKilYY'|<%}}}=; "38\Z`@gYrc[cKRX IމRe?ROII]bL0R.)k+P9U|z<n.t- Bq3=wt?73h%mYYJ3E<$_|a6S;)e}9صv}z䍧G:O ^y7s#T<,b00$^-AiuIg ԗv jϠ`0ht0پ>9h:GQ "":cet(*˒|$HO/JXꉰ+ )]b&G&1%35g--JiCؚ59]8coLgwIgk+ht2S뗗dN> ]ZlARV`~~>BzخR}AjԄ7m[ibgO}>6obv5< ]=KR>A[#&I$L+;(<}Ž˾>MHK Qki>QoD1KӾfM5o> |sw-@~|wǥ4ݶT|o%އNvpGRCGYWn͘sx껭묅ٓf>tKmn_t˛caloy#\Q'P's[9^u}SC4'M7M(̫9V]O{P u-_ ?^%<y&*^vuIZzmx`&gVk@ 7囐󦮗qqqrtF-uBWðNbKŸtvurߤ@ ,NPWǗ$ X u~tؽ'l0q@( S F1I>^h2!g9@<6xjBzd.B5(MNnff659WJ]$L -[+[&_,%Y[4^eͱ-W;u?;. e?WƝͭF`h9GT'mnnnnq* ?-u0q oq-C%EFnq+p@+-R.!fW\\v84Y=E`I#hO0BΎ"h'}穱k=Q(L"01HFY bho{ԒRKL֮%w=S;LX綟s灉m?4V/2Ywɢ1Q ד %ZAjc^pUg]dd7@oeyǾM|}vn7қ#Mv}19ai,K?{n[u^8Iq J*--Rq8䓴8M Gw%z{vSxFsru(x1wĻ^>XjXY,!l.+uĆ"o>nLs7Ema({ť##SmΌ͍,}aa\rSF8B?{yZǾd./bϜ^PdT^O0ך*z۴W1%w{߾;2M+K4h컪߱\=Z3&&0Kl+/Ya4R;xI2cy|c~@ bӃ`QK9-9iKeVc*-ج969F̚c+Hh>Ǹvf]1mp@Agšm Yvf;N8T kgmEnu#{*ё(jw:0r "q0ċXDܥ44IȒq7,NXdx$7M1%P0ѝ,WL\1I塷vxeol +Ynj,ss0.ZZ 4;ufDK )f% W[yԳE`3]w:/G89;Tv7\yUՅfM=^p%9k6N5TW]_&JݵKea{58-Y.Qce|:ITz.׮8U&xgA&mzq_.{8',QF&2RHZ$„[Q78F=% %wa&ꜵK@H:2RB4X7X:i;;X룽A?# s+O,u]z{qk9@.lnwU#Ve׫sx ^[JN&6BAL 1[é{P"/NpJTq,)aSf}'Ȫk uC1W;Ŵ0}<uOTE'7₃ssy%i'Iά[5Ӽ9)@w螨~/'-+mK] Hfzi F/ݱlF7g틕H\ZinŊʗFk՘ GOinn5L@%ctNV-vh8%zrƚcn4HdՕ}Ze}AH@՜X)xWwm`.u㮉r[͔DKk= }6bȾ-'閖Vgy'8]JYI8 28BBw|A I"[J3 +-0MGQMc¡=,(}v\te1Ɨwu}<lL@]U+8-e}K^Om%s#*O}Ot)Z@= FF߱jݵ!,95GR\phrjF+ A`ZWV0M[sl[,KMty_EkKFzk㖑VJp⻵kUUWT4bt^q\YUrO: t"Eq z6Tyx~(3k ǣNa:郚*4Qт|*RsWTN| 2y]&?Nx')8mQ;T_~<NqA»iCjT55tz螸{7Q&5<ˊ'Wg7݆^5GknZK"±)a8x|vgJ%%W鈒a&4T?I?UWuDk-ORغLP>!йK<z:,/N0np^'t,8M1S-١iRIyݚ;NV$ J/{vix\.kO$h\YXw%kogMm9oWKs+*TtpFߖ\^w8Na8{tYNWXX>/=˓?Y]ox,rvs/OTtMwN v?3|aMZ61٦_O￝% Sg7T-]/j4-%Yϝ_4{{oN<{58ޑ.?i:su[m|X rTw Xޱ?) ھzo?ufCUݵ`O>uV4va{dv8fh߿vtL&/z[(k?^ujJةTSB0F䵏WLrŞZvm*m,O|06,i^e羾1Džv\BTyߺ3{z -]4NUU?Z:e;,:]"ㄙ﹧^&v6}@ecq]9,+GmF1 mhhk9 IDATx-DFDn,,ėǒ5v"3鵹M8;NG[T0٭[rLdٱmg74%fJ}媬mwJv_I>:jf0qU5gtOlnXSU< 1B;Oogj۪%glaJŌk&`q5Ɲ޶򭍻k ;Zw؛'$6_yPTSh9h?P`(sp8j`P$ "3m3 sCV*]Cx$)fJaq*v 0qxajo>u[-kjNfP[$-x]M5?c~eڮ+wו@z  xqq(btO1nѱ00NMv׶zxch>X[1۝v+1'nkggMsl&Q-;;sv=7 {{NnachV1 p80nѱ00N87.1 b00N7r=Z(Wv8ԛS qe5V4]);pm%ᶫtdmIkz5^9 ܔdR81<^0 88S@xO8qq##e77y`gsu8ety _hhk6HQQ'^äWhPX#qqb00N8X {'Rn]c!ءX NhPaBzMd&δb 1ep bN]886{8Fy|ӌfűkAp\Vwa2)x}X:{p*v 00.ۤqb%q. +e܋B%q羭8T8ɅKx00.;q"qbiqeQXq,BkVW_38$Z\qqhK|B NryR8188NH-81#ӿ%9N*=Й^QS;aa2ʺ>lj<aa\vDjW0uX^8ew88eu}lj<aa\vDJx=aa\v☔u} @̔N_'>s׃~/Sa`\Na/Dctn|sH? x/HEYSpIʗ?wr[ '|mBȔ/EqIdMGiI.f ";*dU=N00iK;뚮nSo\4M;x={}㷊bqI)ry]0??C^]eF$7=Tgq*v 00mJ;ا'~tճT_KsGlkn[ 'v?YЗw>ǷġO }pWdr8nk:kwl{ 9{VJs=7wVqC`beL&q hx=?qicuZt$˂~hA 3#Ajv#'z>s+KIѾ컪߱ '}s0/xũ588I4 `ɲd wD")a&,ӗT 1gx2w KL-3Ǯ ڵ輓 ڠI/Lx +CH'zRz©588ItĉqdϜ9gΜ5_{/}g gN`둓g`͖ok/; `rû֏Soz[>hɚg-{J&O'02=`7ݝRi\VwQ%?Nݵw'a;.)>;\P`dV{&k&@!gz*dUֽQ"L+))A(> ݿKo5S~7,rߧw?ſ)2w{߾;jD$WS@2,$.Jp d]MaoLƉ b00$QI]y'8w5Ӟ^8xx*jlmOx"ɖ=gw~o6G㥊_]N%XLvƍ"㑾9>p,)$˟;xx=_I[^LE_@wU%FaIw\Rʪoxp*v 00N,2i{ztw=?: G_=pKRٽۊ;O|?;Tz'yX0Xv'z<,,,,*YUYdU=N00766Dd RgU޴!ɢ,i믿Nrl4=`W>-3A/ Kw|KRSƊ. V>W,N:d9d ҈_ff!4EuCѐ'KdmUYs D\ 缼+R}- [ܻ\I9H4޷ojw6, R9YV`ս[y](,f1W܀2XJ,R NVIENH+'^(]88Dy UuŮ?ό.\,Q`7;1D;G= |/绞,xxI+g00QԷcp*v 00NV¼^=t5JڳD[//3%ʃ_".H8A]J,ńq*+$ϼ$8^88e]O} Skqqr;?/,tc"Y7UEA$Ubġ>B31epqq,ʺ>lj<aa\>\}$2C11FcܺIx)I{LS6oR:MuڤIn&oONܤ]59Ж.vqbl %‚FsD3#-3a={K.3)I@r$A9 B՝?s<|pmf_Y+6ӲgNbVUUUo%gi4?L^%KRnUg!肿/yV,n߾=-pb~wyeruWJڕ]OxCP>W.,,0$0c< ~v>°睞Vwۇv,cl;qL%U}7ڷۧ,4&˥d `tIGIt}CW`;`u uCxeq`L\&[Z4f䵠m247?x`;Xm ŸS[u|ƦP\e-ZE̺V.5I;^ڕ]OxCP:lۛ<`fŋ=1HOfq換epPIHp<>̈v1WΏǏyϙy9Lͱ$K ؎ac;P:ԥBp<`UP\[Q! Bmmj;ۯOFR<9īnk8wņJ]?5U:ԡ.iWv=1uC]Μ93110::q 69(ȴsn=sYرM/m2FꚹwةZfU}|:,Es:;"˼J<'SKMUt eM!0{Khy;:2c;^I2J'P:ԡt:]ff&*6?(k7gne><*q2s+۪|r4,2Cr2ܥRS6yxN F<,I$0]{H\p~OKx zxYxMeowXV<L_w:y)+V|1bd!ܒin%B sz׿{ W-8?х:ԡu]d+?uw>|^? ,{vЗTv/vԡuuW7:>Hx8?`u uCxdzmxCPh`;`u uCxE]}z2?^K򲦥E uCQ $v<P:|@VP͂\k>;L ZZSݖ<ش늍;zu[h`;`u uCxds;yQܸqb|aD8KP:1=.779Лǜw !kn˼D=h:6ӔG;.5G]tԭv+qG4SZefx8 R|t~ռvm]boh,ʂ5TY>UȖExkphCP r](r}kY| W#u u_U@ 3Đn/d6G.% uCRj{2v=l(zP@qV9{!xӡ҂X9L{NuC+\nkiy\v@p!wg=r<apNuCֻ.ܴy~oqdyRݤsrƜ8< Oe)ذWOV::ԡu.TeKG_ /}{96WK]| C֣nk)瞒<^uu=f&P2i;?;oYY< bIFݾM;NC򼇐mln,ཹ=808KXk592WÜ0:N]@kҎLtu1Xm~}E:ԥnn2B. D̐B D=,P<=s `T ^LH G&-Re^9iu:eF3er.a$F ٤I|+xܥgtV)?]Oɬn\:}upxmj9u(T&'c $okB:,,"v^u2%.3%q&`=_vi}b]ʟ6~Yz]1&>}:9nK[Ѿu+dEȃ.U94]jRc1 %Ԋ0o89w#q1SN%e4]oswSa\92F$_/@E3'Ny%x®'v<ԡuKzgm\UUh;Scm"֭[Eߪ]6DnX#J<'SKMUt^sj37?IHB6r,)/\`'e4]_Ps$0H#gU 4ًD;MӫM~\=ï\9 Br Β3B::ԡu O0T.Q*D$2r?+ {h˹Merp>jܨ_,ڏR}]2uc>?q㹶g?{2%$ԑnCo| }&>3Σ}7b Rz1 9ecs;rc'-Gc;;~ߞ/fK"ۯ\Ac4js)A03nX7qbL"]=ַ%Orӧ3 g^(66E0{ efW`;`u uC8ABp\_u:0)(phkW>MoƕPft{|KBVPr~{cD]{`:g$>ۏϵmiW^ 84ԡuyŁm--`}mqcĹw"+oc잓sTUG@hӅc\jQ+f=f&{&O"1smWB_̧L6˒eIz,g^KNuC7_hCUW?TuBMv])ˀV͈.&(^_ԄlOHgyUJ]=To u>ng4{f&c&Z`&]H'ȓDt!8otqtP/xmklNuC]!Z4 83f?^j@RQA#D]jt +~Lp'uvP:ԡ.=u,YjirOtfN ~ԭVnj.f&UR`I+`?]ĖغS$,Z3%KiWv=1uC]zXҋ b1t|V[)fgfB)?!:hycj7,93KxY˒"#zblCPԱ$[D }m]\ǼaW 98!?J\Y+KL^aWax*EF؎:ԡucI̟Rߎԝ3)Х ]ai S;n 6eɸ(WBu)x7FtLGQ^u(疰9EF؎:ԡucIZUKc cbΓ!.e : IuMfRfno\@6  ~W]001u*/2®'v<ԡuKOKּ?qxY.qh:S#3EC+t6EUa#3]&($W_vP:ԡ.=u'}Yĵv k*M=q/b<%ޖ Fѥ՝;n+H$t|>97DzmغTve*i=phCP !\~h}esZ?^';e$߃t{L&ۓpq騃:6]:z'x|wO ZL]E'tLEb":bd<ǡĘU/.Ť]Ox=J'P:ԡΜ9344ĤVVǝ_bkkyG/Y? ?}9ZM(fNy}`抍uflGNZm ϔڹgQ[8A|cSC%%tƍϮ9GYrFd?|yZ%UO^UU^p$,~I. EQsr"}.6'SIaG{[v̒-Jϼsg\}*Fnf)ۺ.s1#7O#^EUش3_ Lܽe_ ]~~g޾%OlNuC'u:]ۇp W܍8Oye.r7 NGc6\:w3 , \niPw+AYx]֖wk]Ysligy%r-&&@[Ah+67di]n»\2BW\o@́NtWlwxwSn;ΏtuCz׭))EU)qB_ROlLa?j'Z13d xt Z{9xmdj[k$aL :R^8b; `f!c㕵]l%K uM`ߘ'+6rjޓIHb'MKw5ct uC8aff&98 ǝ_iX[n|rw;y_s6Z'`>@Eq2 C; 9m>6@y9mas'Pm۳DwT/ڦ=9.)VdM0wu8:! iTѥFǼ(6ɌcR(T&'+ ` ;/vmJnANp 60 `;u uC8{ffIIMSsvwE&k{{{ݧ3Kk0m$6 CdkcNԕ=%yѾ5ri;5䝆U{{{W,ý^& Z}j#v734wm KfI$ixN ;-ʹkcSNfθ(a..PnE2 hy:;eK|4ذ)Fm`tS^x zex-v77a|yscp kouhew{' 83BȑnCo| 7!aZ31sEJm7΢qڭ0Wda" Z^1=o=^DB̲?s4?х:ԡuQBp\_z_jii?/KNE@?^ nNEٖˌҶW%YN2 Ɂ]PSv,utʍm(|713庂|5|~:'j #3#r~yY$ xԕzpKo!mEFx=J'P:ԡW^n]ZXWq-!qw-P_>T؏SGmvkR)%WyY=%yAW]iInHmSMC=yY2N鉦t2XtQwu ?$̌Ap{uF[U089ڟ 8nYEFxkphCP  _77ΓI7;`7ɣ]WU2^O\\PSCB`\){gOiۚNfm^u#m[z& N:A~%MҮ'zblCP}Lz%t#.]2Ubr<ķlr!7U*)Ӫ*oT*LK9IqQsj|fu6תRBw4دE;w9}e:ƃIr{f\rdsN 9}+3wk]˖DtR^?\whzKKT ΒE::ԡu 5%A^~_P(JBj L]5|y-s\}&O|ۿk\lhqcu&L14tr4bvpkrKt$Y"Gkޢ qDe}[y'_xMgN7oܜ?3lZ,{ඥC+Od6L48`u uCXS;C bWx^q;W&y= 'Oe);څ˗ ؼHY 5j-te*Sx.a亊ΝD:2hk}w)MsVM/ȽbT4-fF<g3IgFڞ;L[[$RH$9mB2s/)JȿELo*Y6-;  l{ggU@sK>;DUM&yFD\l ްnw.f"ܰXkHjA9 b^Mo5;L.i|[βn{j%S^::ԡuP!E7fd~ko ΃4+#Z0NyO$;^<8/OIϿP'<7@&g^Jk z ҝS63`Y#G3'B6,>ɩDtd?'AY1?XO}v/o<ݼwւl8qn_Ke\ yMsIH|cZ@1;wnNLv3˴xAp~<ԥ uCֻ&_{WN渚׺'>/^?'|phC]D[u{@թ-y 3 ǂ2&ML2_P:ԡ.=uAzwڊ !Zۀ _@][m}Iu.Y,Q~sD˶)og炬ŷ(ňFڕ]OxCP>q&uv=$S$p JHᆘkSӤul#+nҏ?H{{{vN肿U}Qe49w{wiquenRK_ngK6󽻴5Xs.Z+iWv=1uC]膆tddtb~؂o)ߵ͘@=iVV_VUU %QtUy{ ]* 5uݘ+mt uCXr -v77a|ys~ik;n*[u7R'P:ԡ.ve ̲߫934xE2xX$8Ceْܐe.ݮK9y}s@wF\ *3^IT-C7-GXmUsE;" :B/WT%iM{@$ڡ~`w.xiH*39д_/w\T~iLGww[)"t55`g6tJxrs're*Wu]@$ Ɛ̤]{вؖ&KWkZ\ndltehUԖr=cn?cY}x+wS@Us~a.ه{$ATE5{8QP;Ee5upwW8@PSKٯ<>o@U{"I@+`az$u fKԑvPzB,(' @3A pf>0aUt^}'-J:3s.l33=bU.׭*@3PJ3D.+ˢHW $,"qX\Ģ?@2ϸu,; jh#]L \K׼vgI"zS,VnkW'u.DLfθF.%-% G^^) t씪$iOu֎sn7hd];!ZW:޹ nPWPS 9eKU묎H)!mlOBiXxQ枮چz`g{נ.?êc(sW{WqOҙ_@vX(`[ Ӆ&BCɇ*e2)kju9שJ/]\5iRְ 9c54*U}gXǢ#~44*Wen԰).vᐞrWYzzƕ >3gqu:`m-y>&3KU@YۻztljWR=f]>2e" @qZNڕV7se艀hTRX6tz+*S7[H\T`D{pƝ%aH!E٢Y"DjbKWlZ)/8\bX: wODC!fB~Ux}D+C B|{i ]u! ޫqx"D @ ,Ktr@#D} K&J:?D Љ}4yh)!V@T$ yh`QƋQmqu .neKEfur]ZhR@N7fMT+Qo*ۿ|ѾG_ ^gMvmMg94j }ykl y$U]֐e}#`- 4%55:]!Lv*|I"}ŗg)-:gƵ5g*.i,V$֙:bh*uJkt"~t7WɒZV-uAPP@Z(T2,B_1 ytDiu)t4Z5NQ`7Y Rddmu@VXWU֔2>ѫ\qu] IR,D2#8xvc,HMC)ٖRiH![ |4N,VuK BRFqt"NǺY_WI)::`eɓ3:1mq\2qG kqt4dHDe IZ+#lZ *\>Z*!w Ь[/,E 1t"qHߟ<#Kt pxh ,G'->hB+ghWK7B:p[S'Xݠ t+3rt9X\Ʈ_|t%˗jgH=u'PvP.@hI3iNU^[KeUZr&P/REuR!*Y28 5x#t`ᧉMQeMC'Auom=yB1~t h$Kjvl#ՕDvQ]͢i&h iN9lPGGi6J A$KP$RP<D#t̥,gv]>n81϶oQUV{14cRJvD`7DZGϸF vXhh"qu!CYdKqu.O`H$K /Ջre\Oy|tJ\'(N"; ;70#5s]M dg'qթ3%;\ss>tRQ?YS4MW@W!Bi+ec}d`A&uLI]&mq8.e5qt4K6AXvDm>q +`^w;7vGw M |uu(kYfhhDimc)e4JaWGvK+KU\S3]yd4{4EŪo%nU㾤_KܑfESeԸ:OT7_pggY z4{j{JSTS)GQ.TV~)cՑݝFUB60`2}̵0`.BVsVXԭҗMäTEYA[ˮ#ΑچZ4D>AهGHU 4ߵloVW3ntXLBW_pEUR⺎SJTFԗHBS@ٝuN#fJ'2DqCC}5ZzR~nh)kJtqRGG&=%[(Kw{PUƞYJS_lq%v<mS.(Vrob뼾(g؜ @$ޭ/rN:\in9\;RjEw+DOnWfB_pV ^ ݌MopR<@~I]f LLxq_C։͛7OLLI:-++[ph }]O\j1%Lx<ڻ3q-9qEGl%#+rN. }xsC{7ޞc"WEF~sstؼy}*'>}PlP,.A}wz2 uck1"ܟ{6-s\8Ens+5} K4ŵI?!@;_phCPP òiρ:rnws3-͍k;n*K0_RphCP @O2 $eLA$qO=#<7= Խϛ¿#yiz 9_zi$tsme7{jGf@Z^ܣ{1GKǍ$A[iǣǟz{o2Y Jk5z,LΚ{W(0>L^e6G굚{-EkcMOi@ATHk e^4Xzio˔KZ۞yh`= >AnE֤^kg_{{m#Ɨ^IR`7ިP|46xx} rj2~-b "*lA_^}qR!f3⣍ 15Ï4_ei@,&g: IDAT"Dž$A[h3wa=~E=/}P̎ Z(_xoF~iw}Sz,ɬ\fw>\WW}uuuu=?źn_ak] ror{&h1Ү9<<ٛX r+@ksVk+=#vH8Z{s-ezyf؏;D^y鮺xD{$}O=:Fڇ#5 APT[Z.UWV=m4z]M=ٷgeEFz_D?ϟ%55wIۍNCCu"7NjRcK5Z-`zj^J@ZRYS#vjU^cPl֫f mIQs'{myς֩٤AᚑWo}P]Yh#>Tj-p0E%ȾW{]YTYS]B{IHd|$;nӮ1⋚ڠ|`01Zg}YE,(?`>#z?MTjo}/ػjzx=#f/@NIumCI]KAx H3 Rq᝝uzʃdi0Uy۟?oP8O+W*`i&-]=ыxʢ"Up Jx) (K^xģ]AF,F@[PB_Pgͤ} 嵥* t9+ JE~B%'tvE5_ zIwvgJ闪J)K=qw}#Ǭ`#4(w4wLRQIS>uMOE<A4a Tj-[{: K]o1>2 O[GT Ͻب/S_hzVT=~k"KM@X ཹ㢯dMAi"fL|d"xs"](1|$4^oH'H"~NIw;{Ύ̎}Ok+;}dXh#$Ѿ̀ΌQJx;اt$4NS,X$NHq-1Ps;67|>} 2rD{5? a׌ͬߓG5Z~^kOg )sz]m߹zێi f϶w z7>l,`qǥk5RuB"@ԝ+#Olu62{@'W/F TM ~`0W^Ys3 {뭫C=xⅿh,`O?7;ںu{믭SږZRp%֭[]kz[}+lJe1m!]xmky_{_3:.u{y#-ov5 ?K?w( ?[|K 45}U?xw.0 b U*^.@h ǗwkxU9]W~?xeWO0ʶ=K`>f>3зP3Na+.H^]{nƚ M_>x/ _~e;]^+_MYo4%vҗƝ>^-Cûw7tA'l?'4|Mx'a0 d֭=g>܏`0Z?^'fjP׷ܹ}w~`0 `0(xo{Vy `0 x`0 jb0 ?칂`0 `0'0 `0 fs< `0 <`0 `68`0 `00 `0 fs< `0 <`0 `68`0 `00 `0 fs< `0 <</O?'ğC4(?'ğDG}@ >?ݻ+x, yޜէ\&6 8,n-ns}3I>`0Okx=4O]`0Cs< %:`9:'ZHom Wm[!o=9}6mMlaqX`0Xx?>[_g/ߍ'xQ$`>:`9{9m>#W~sΝ}sou78sڝ4WvMPb|zjGq&t~[Gδ\U\>bVx$%E̤~1x6@,60:dPsm+]ɲ9I߳maEQ6o ఁq&ݹ Xߖ>6ã/}sv5/Wq}۟*g=LɢɥfCkG>~"?G K~bV\ToSxY`ࢩ7eC[}ccnrfv_~K%SX]C.=|K3ct s~oO_9ɿ0/_\KՏscq)ٲeg?ٔ?0 yϏÑ%o~ygs۲i_|^[;[nV5Qږ豄/y̭(P5Y艊[JY|x2Zo ήv%K欹Lb<8? ֭(=ږc*4=90:h,{M·= aÅ=!DJѪaC*]*QCB$!#$6%[ v`xGɆln^[I>j?{-9YD YUktiX N_ Wۚ L̨jEe[D& AV'MndyC:rh,8v3lϙW)keŌ!SXg`Mծ3])q%DA$s%fWTRu6 dtзZӋy>cO}9VC(H :.GѴ Z%?yiΕDCi@l)!&/u]ZFE桝- y A[]Y(omȞLjтCp,D%!dQK+G;ښ0,ؠQ+EHOy弳"TY@W:NwjHYqD]YRs*A bGmOu5O+.u] Knli g#~6P," Q:PQSN*)'\Z$2+vHs6Ppu] U(VJ\CK#a3R(]MPl`;^]VCw䞱Jۉ_QQXD]CϺ^sMTQ%Ɠ_@i+%O' `0 f5O6C[=8\ _oh9"x ga}Sٵ9G"n^fO{`fYUZ C~Vc\u4/9E ]_M.5_ɶ+nCMY&+*P:%b~Nq>P4VRhr,`ǸJ-6+ Ɏ.a'f-$p/vɌ ~Vx')g5cf9RCxxu*)Qޜ*fޑb)0mnɊ|m)X$-z)Fd-c;]Iz]ѝm`:RLN?CN:nd@}3RN94 vPvђ f :O6d_N}J?BrHqώZzGe GpKjZ(6SY󨠶X ̤KAq/NuF$::ǣٵ !!>7zZ 6iβ INU@-6$t]Q ^WQ[Q8f6J O!G[gFN\ʓ%}Pw{!#ҵa0 ix㥉kbgu /D[D܇-KXID&g%gB)I/ BF IhJdn9yTS~wڕ+%QO-& 68,h$ yUbQ&E/I_3[UJI( שݔd,6E?;RQ\ߐC3$zI?Pm>}%ycr'SRIfT5^"R~6g0 'ٽɪJcTT%A@ۨP']` 4+kKa,k9;Ky/zkZbs,@Fq1b@J bn>'uK~>y4`';K<<"V4d D @ȳѕ0(3)ϼX I񞆓pϽ4&O+Y7%Uuedd,P$;;YP&L W$F1!A2H>x+‹W6AoD!*jZgF]~ mVKHXye:^jcG(9T~BG0/?U 1R=;uVVqw7wks`0 f\l_8}˧~c܉;+Yeh_>x͒ fsHJ):;:ĂFDx:zٜ&!-'f|.',D'J rjxlXQ> Wu]|oջt) lY.%غTT.)V4_W2ubNA#UԸA*qa6b@ rt(m)ԒRǭ_xaaAB HF>}R}"e1P~E T$푚eYPVE vuHˮ#Hb>īju;:n"%@d G;m{QVԨ:lѴB*l~{╪DD/_slM I 1,;iѥ* "X KMB>F"<4Q(C65sfo>@l(m0Fn,Qv6Dr<$/޹}zqI UfXyФPYzUe*{)YoN/Cf x:aGjޜѝt6 u#}"]D")';6akJ^\xa<Wj?w}\8݊#17Ht]3^o p#"~^(Ln|qpjv+:0^Y͂ g?V"PWѲu.ªw I2068KZ>fwեzYdn"^`w韀6XQˤoBr&`4CjmRj,;1]oxFԺ'zhaB3olzX=Sփ#ƺSxf'M1AfZWH2β,C¬ƞ@ML3\ׯ71zgO* IDAT8'c?IoθtC/q970{jqm!Qg:+jHQ\Qe}=.:=](ߨx[m*£ b D;/HH!J>_)Y)Hut'y`ieq(k]X[GAgOg7a>l**hגsO׬g"޽럑,ׂo׹]kY..%e/lC$EPcsiM8:ʵԴݩ?9b*eh1)r%sxyz4IZq'n 詀9c+00@ |2tuMOg t)#z)C )#IH|2CD88;2"+&L kZJk65L{~ئYiϰg*:@n<Oz棍L K)晪a0 s_or:ޠlt͒>b2Ą4mi}^#IAaҫ6:` `s5Fz`$0P g Ev]=bҷ֣L5cLVSUx0N0 [eirYj5z52j* wz& JkLARkV!2nɦ_1"k,@c{AFYj5 &9ϐ'gKu]wN(& K@nJM7rMvSR.njZ;C7 l#=zIЙkzLbLLtޢQM;u.;MDzU 5En´8iF]Ʉ榹]91zlgԮ%MBeL 0M=vmw,e,HU =d4b 0n71K%*̥CI26T]W4x<VD0PzF [,2Z=1!pUV[ip>>;\+I%I!' חd64Ec+}4ź1ƑuNg`X4X˂@oYu+ݣ6:dkv0IyRhY!3XMGUVE1Ϩu#ƘSF@bfFUi<1KzN[ z nN)9mOrE4-+;>S1u0.L߸z@h3֢}q-֪Lk&ݍo,j"P:&}"`@#0$\d}U*I;F@`-oUK_yq֥sL4YtnCuOK+cFkY4`+;ZTq #Rh/7a=o5 7 HP Å\F8iJ_}ϰ>IK4$z]#uBsI(GR2[1]\4.P^YB}|Ka4` >5WYw@]iH> D)YtkBi`ؔhlXdU!}aaiϵ&@)Uunu9XH-9 H*`8Sm}>Nq4,ⰸgh M (չh* q@  : ]Y‘,jv B&WjI ca@Hi2da. B*7 \Lu:e߅陙$LM\ժu̢1D 9u b B酔sI8nndz.pZ+ @j1.k ʢ.]RAsZzL3 Tiu `,aukn &Tӌ}ȈxG߄25%, F  M뵝1ba&&4݀0KWܽfd`Ĵ |LL 27|{hEq d_JՔ0UWKL9 O݄iUә.11!pHtℾȐzK̞iċUhH-g0\J@BhAd`n"@I )!)^[D( e Bt)q4Y9}N܄)g.E@. 栐 x@g5YTzb߁J5g?izX3' 8,Y$Sw1QYb榧#s2GB*WqqtoRZ1tfd".d$?pssW((8ނʤ6וk_ʥ\MLqbM01RTs4WpX6lI5]-jMȦa‘Vei7 Y. 2`c8`|>$A֕3VP5 ]Cq:N[cUy&LYR&713=XNA[Ngϙ*8 X> LskdHcH(Lf@`Cj2t7Pjsy~juNo駭%Q뇽Z3|\bxs"= L3LqdS#LfRt:ϛZlWMuK@Xk7'vr@*)i,<'xe:^t_,ߊ,oA-VoKobӰ8, -}t/ @c =w b `+o}<@ NBu.XuQ2R(pU3jz0$c d RkPF66P o;߲i2Zc1~CY?pUg+ >BɚӽB!L6 6@Udt Ve0 ҳNR$0ސЃrn~NTg2Ն́3:a7-$Ks7 ǁ]zR@Ū@ Z; ^~qdQd#̢c[*QLu̚GB0.mn|6qx_uf:IL @ s q 㖬hG:)`XC Vu=oyucйg@JnMz&6 8,n-=Weekޣ=; U|V@>]7p'E^x;=:m4nW(ʜk#}*6xs:5u0.=ݻNq'ͳCJP`+?>[_%D1n+<='&-M8,{aFխL5 kuڴHJk{Bb[8M:Dٍ7VVK>[㱚+dV7 \s+U:!!~CC(HםY׳ TgE>Jا"4hwBiO:d! 9lZHziz$xA{ 'x||uW+, e)ܒ{xo3pZR>|.mu!nKҁGU*t9*n$b^+Xڮdٜ6L0jv埬~aQ/l!Bbs"6|l|r3n4t:.bO./ű3c*g;ri12ېbq.y'r%<}xEs_#}WQʂ _lxd!Pc6I$pD>~_C#J`0yx坹mگyo5JV%(mGRGZ|4I'^an ]E CɊ@OTjVcWǔ9xJmWdΚݏʹ-ƣ9AbZbCm9BS]ݓ cƒ a̮Ͼ$..|kshv<\bJ6ҵ=$I"1"Ib qKNx_i,3qxRE;yp R2V޶fwɆl3NB$ /4LYa^͑T}`qOa0 xw_6ϓ_!Rsmw>pܧ4a_o6 <(A޶fwIkC6\]Ƙ4Y\.}м #X,U+H)A<t3Dbgc9!GIyP奪70I69#Ƃ3l ?FyҾ;[_yQ̈h:dEQ|&jH[Z]^D" 9Ғ_:9ym> 7k錙o|ƞrYۭ(Qy,g$04׶`eU9CY;ow]]P- wy>86D4l# 'K2[&W&,Yo|;^D(3!)gW-6 Z՛~|CmhBl"i!4_ .-D Ζ<fqǭ cH,6dRvhAơrwfNȥ#Bm`l˿"҈'ürs_*, pثfHYqD]YRs*A bGm˅Vԥc3a֭ 48Ͷ\Al&ED"C'B7jI"ҀKDfy)a³|  S ŞJkhiv$Lr&Q £]i"2 x+K֊v莖,ܴUyw]I"X.!D†50X`0<mI#p/'~uEsC嘊/w@RR_MݧNMe⚖z%j>_eU5Gk)Nv4Y*ؒM0.;䆖 z;̎qѼ=Kl"pM$_qj/6YU܀2n)muU;fa&)1f Hv?K|cK o2c߭ժ 87veM1{3)rJJ7'+ʠٟwX h"_qjJ({+u^5GK2)Gsc;@W^WtgjہEźVD@uddS**;f-!F"$]eW{w4;XHKN9)hU$.yP쩭Qmk=uzlF.5gD=բǛJN>a$I9"—df7WߒdPKm%- RNKkʊ#DR+3,(7tmnq ll%]ףs(Y~VQ9v,LT@DaTQN9JI H4>lF@!?"R;Tfi R2[3"# ćU &ŤW-Lu#+ ^hfbb 2׻`@1Q{g?d%u4=3nP5ov6oqzdU{Y_afwD*TR@(_%>dT/#B.E!mG$灌 x{y ) Oyk>bsͅbà@JzLFKD|9IoJDBQ2B(Lݱ6s׸F ^hf/&?|L[BH-|(L{.)>D "L9Jz#R Q_/ ;)D)ƯdAvyavG=wǯ] Rb`̂&M@AWe ?kX$$fz j·+\PrA[S)Xm();,(~v*#%!fI3D|JPƢ^O4`Ω6 R~6g0 'ٽɪJcTT%A@ۨP5PhI3HhXV3}dRG>X/6wd+j Ī0M!vsRXgGsvo4#¸lECIBB<] 2+i8{|Mn?"u^*XU[vNFESe+4pEbr)?d 䃷"xS"B*UԴh)ό ")۬"ٱ KK &ʔu%7Cݗ&&rTnu036Ė}*ĥ7zO_#t~Bͥy67*Rvs?d$9=eӜ\l_8;5vXwnؠ IDAT}sHT7K.i"X 7+>  KsfsHxC@ȗmrBy"ff1EQ{u],VKRvuȏR"Ky@|OR)QQa{Mu%S,4Q4RQuNDYPWfX,& $"GW>a1bO-)Kzh$ dݧ/*]s(mW@IQv5`eUԪlYT:$#L*]Q&YD6p3:݃틔5y4-mtJ>};ߞx"Qg)<\>vSBH < NZtţJȯ)8g>%#3 2_y}$Dy~i܍#,Q.Pym!ek4]4Ey}6X,nQKaյ}X^:m,xH^s+**.I=Xoyd8%2JtŎ 4TJ濙,ώVI!PM.yH$B h̫?_>r.OB4l}ɡnG Rvܕ- jFL,#&CHL@sf)MXäWYmzu` tوUo^ @=bҷ֣L5cLVSUx0N0 [eirYj5)жB]'pk̠ƈCj5%d.Mj jtV1aB;G`2bl+`74 @d0x3 ay0ZiW@nJ嘑^c>l0 G ot&nM服ZĘEsv\v6f8j܄iqӌ0 Msl=sNc@Ψ]K9Z3`2zt_JYxH @=^9psǪaS0.}Y~µ8t@p)YGN 9AiIf3*spI`ˆ- dBn5MjHhM4m* 8H^[}Xґ:ߣkAhwQϺ{_5Y岗:`Nܗp j68^u͞;mm 8wMZkU2Yò'2,LW]z˪',=jcE- teӸ-9dz˰E#dig@̡UQzHS5-1];.:it0̀LNk',Vs@ݺh=u{xVliN޲gb:K]=|=I c^`6֢}3V [gQ .;Mr#oNW]- @sWժpVp`Y`R S;ui0]P׫\ Wzʘ"#ViV(\ծ n{5{X~j|y ˣܼ'\z6,ge83WOrpo|ODx>9Bs`___:B|kwry2[o5F,籌|eU:AHzMXպ_7``utx:Q0R<`y/]_j-vUye4uלU2gm ڗfGg}owҭ NUSY bh5u?s|ƬЈKgg:^beN9pS̝}v%Kzˌ;ιa!AdI RDi |ƞ`? y EqER 8DVv|~Kuo?^ *%'س !6(9"dr#e ~Dݧ͗i ,%/vc&n#=߼/Ç} 7o-ɲ<`  :@K9<-Bzt !'HBHOSH܆siOK4 !ap `;6l%[eͣxG-iK{K^'c$y׻eIwշ: W Zu]F3%˗NX[*~b"(st:j< @r`PyZ+Z8tuZJSu(unUEtPJ"jpL[4@iOWЬSSW z'gjj@ɗ94ӧK:GePL]8u O8M\Kf^1C]AU2R{wxz(UlĄ.{a[@lUNCѤ}$-֚;JÞ0PڊR5KK ݎ꺺uCeKi)ZNNgh|jU559n5ʜ&6*bB򋼨55&*u_=xyA]_u˚C.OSe_ltی=T]"Oꮿ$υ ռϹzuaC#-H&JTKc{{;l}gs4jM&r$=k;.gVP7';O^9USxmᏤǟR}\6#V=ý˸oo`(Uf?x/hW|**'O]?؆N2v/ѮzvS)~mLPg⧧JMDOա\rj5MPLbC) --&O9tu#GvbGK]9rGݎN[hܺhu-/`֡bkLGMUjb3F7Sp 0_h>gׅOhk~R fMsh e唡XL& @mjn49)hejna{|<2|;ZM؊BYSo52_eZ-u|JE.*}aFBvvfَywg>ب:u7{o:{v?Z71yDEjqkܣzO\7}+tpx#zHA@ 2t֚7+)S.۾7Tzͅu\eQ\Smgt*{8ri4C9C*߾kG*jz9r5OUʜ&?w^Y~}Ń%EŎWjT̡wJozeYꊓiz-xU _gp{Gk._]Ooٺjk omp8^8B5yUU5m+ڽO֭AZ4YUQ2uW_phV}8_SlZHEr@Ŗ-2ShykԔJ|=mti}u/Ьۻ]K5ׯ>thbH+Te&(E݊WUQu$<ʦ;x]M6O/uWHE<UŇzdtZQtz55o]q.-߾x#4j2}fwwi1<{}F"'% o~|{?8ǥ{Ŕۓ?*4.l_J<?X~nwz2H ^ ݙ2N[>r-#o<ȅmxGNI /07?}G-HDtro\7V%Yi2+/.@ݠSpMt J }bG7^[TW̚+ՔCV|OVʺK*(ZŒ%Io:ޭT;ʟjn׭_5{7(6ug1BJ! HDRwwő"娫ZTSuv8SUмb <$(\; ;d鏯*6-]ejpn:o]:)y)O& "W=ur+/_n}vmI\'>mwwpa%8jnLU5\ztZ֕je\rB9Ct3=EK#OLu[W6;=fqM;@jVPzٮ׃|ѭZ u]Ѹ.[_,۾#7 {wj[lzM_uOV=UWQ-kwYY]&qF o%}gcIͺ[âa@ H|bljs kơ)OFPB/m|'oQ2!gF-R[*)ӷ9Y4ƽ_`A K\Mڧ6)!H2z-->0%4EK nq`a0wn|mf;OdRU"' OM?ii( CAB`T߽'>gyeǸ!CҌW;>(R{>oj‡8^{ f/)PP.,?ia8;- 񐱌7M߿YG)RfERKvAέ|C,JUKC[(N`A k5m|\M/ǵQ8=_ JZH)C+7ܬq aϟau  vDPZ(r(frCu,[1wP ^9mV sM7yDa<YϛTRX!ʻitMgwz}CCAض٦zRhiMWox>cB!)ʅ۟wB m5)3#C8^j6/0oZ׶ Hh0r|B9Ctst:ۄrzRϘ N][zAFm>aɳ:xЧm$԰A,W5X8Yӭ>Adq<Ak N..X44ؔϘ5iM!)-D&2?}C)PQ̅K8ixK'Qqm|PZ(r|\'cܒ#:w2S~w)8u7cun_N7,!}nu@ T@e;<^,hD!Ξ2|Ĺe-aE,5z~3?{ w&O4F`?:utY9 77oj!&1R4 gNǵ-q<ʡr~^h;nFwoO"rd,l24pr8~loL)bA,2&6t |tm!v`AԆvHH˥zîΛ©6)":nEkyr2F]\!VSW$#[(uLـ-f I8v[K&7c׶ cP帒s;x1wͻEζ#Rzuo4˟~º\0 >ll˲Xtl.kJk{WM6oDxeee(rZ(r\ɹ -,:5O )vF%O&X.ViaQ@5dLҳ+$LIZzvE'VH8~S E"y{o^nK/:I`'ri4C9I4)qn޵KLa_.5ZLX ) f[>򬫷L]nx 4^ʡ©Oe]rfvF_xfE#'  |ISq33|\8z0 d ~#*c"%~n$$x 4^ʡ ʐ|E1(>=A(W 1ڀT%!ޕ7A,&Aw==ifo}|k[$Fx.8xi(r4dq<¿K,#ODͯw[ Nur8v2'i"Do6dd5>ix) +4^ʡBETC9+VͽIL!$ XS0{6B \@bŕdѴG$Wҷx) +4^ʡh\+3fV2;$1:8Mgeک33MWYT ԢM?Pf% cN^A"|ex) +4^ʡrŨr+`HBɴ]H.I)ATݰycxH i 7RmTfV屏@(P|ԤDZ H ;/wy%KC9#8um˸>6Ot&ONj?^ќoH/ `I>j{%9*=t[K2׺A;?.KC9#lVXGˉ*eHt&ON`V~OO Ikq<$%8Zi '/sA $>i ׆ .xȥPwxaz/<9$U&y(k;0 ~|t%V׆aHSd0ۓ5<&}ITnʵ!2 x ri4C9fwφt IDATħkdΞ"h'#e{Ix9pR$\zmik&lhXz EǓ]4K㥡\L@W@T M<}|G203B#|5'³C̯vI'6k8B͍H$dO}׆ HXxa<=xi(rɴ]l#F7IP/ɑY0½x?|ʛ(& nO`qpmE#<vkC$4ć]K㥡H/p$tdJ%aà(yA Y :[D_AXfk3a2[2&v Hxq 6ri4C9`6/@NL\gHnO(+ϘѴ]qAPTZȎ絚O{S#t!|y6$>ؚ3߆ (szk3$x]7wޜ7]7[.'TL #隉+de}"*Zgi5(*Rd5IocTAv.rm+C7/A| )p.g~k[{?loj75^:18t6A4is&SK[8}xޮ.2L隬0\Z3j~x*' iٳCxśW5"y6d gg\ہ _!ڀ@R4j=r|r ݳ %I%٬w&afqZC :Fb/>}܅cxI Ĺyc5r*68<$8)1 1i+$.'?(CxԋڛZ.^4X.agP〷ExA-&eʹ6d (?}A09Lk5w|V(W{t픗iV4cBEɜ&OQ2'cBianjr L9Vyc4j4kخ&rZ=>!ePiVU.ˑ +?0FfhsCC$@(Wc"C2 rV &bb@yfiFgc"c+E(@KѐBqK)UZ`3r֛aiYY{yha2␷r s_$ I|J<kbY #3f)JzRqnhxV;Ȅb :g[S*X"s ?kIНUh Z}GtBK`.lxom A,ڐX)ۊ>^O.@: 3.Ԟkkosw~j`q7uUDxڑU}w]75v4~V'.gN8˿=׌b*cB#1ZvZ~ {MYYl6Lu|m˟mׯ$mu]7qpn('m]h|ހyCow+d >Z҇ySy<OmuGXgK3W{d^F1#k5N\2.4Og~qp_m֏j?gOQ xV>Town|ە }쑆BM!.Li?bxk; ;>^?e{4מXLT.+-Pʟw&H,D|H,$_oܸqyW/rWfL/bP6ugfL/}~Mο|яÀ^ue ݳ|d>}P]mrVCF\1~Hs ܿXSX _J֝75RrZiT8ioN.•O6 Dׯ_~\T'Nw{sȟ +{|kk3f+Եvjgox^*W؛ {9:Z#j՝e ~6|I(W&ˉ_f$j$8Bڐ1:/ -\[QQ0 {^`ww&3x착H N<]?Y}RSY5_ :uii>u牷eWO*֭k Upk5o$ٞi?3ѵ*5Zͽ5o)/ =n8kS Y c%} f(JH$ Y{}X(Mrs{oLf9&)8~~aӖ<" AN?^1Z_MRYӉ:t7.04֞!w:lziTfh⇭$:x!=,?& / u֞ǀ)d]FT.fUUdy^42Aݒ^nr8x'#ҤFp &0_j6|51EI,G66ԵH]0d9qO~rOg٭]/m y>-#Ϙ vQ ^k߾2m 7nZ]iyl=Layw6s<;AQ2G~>]7t'WP]]]]]Ө^"@=U9ufoZC?Q2cx!T@H'dԯjI[mⲖ>nka ۛӛ tCޡ+7W;;@p3I^87gFA`: .ܒpr=:2Œ/] MBg88"n:zxC,{tmDO]|WK[Ms6#H0&-!ɵ!l9m T7nٱ0I*#{7ӴHSMN&M܅_|ohԋj$PM~0xxK[q0ןa> \WO6b1,"GΟ]X\VV4v/1$6k&i:.؉/Fe碃CEov ei^OvV 5G 4 u`$prW>VczԹcat2¨<?~٪ޚ&IG&Ԓ0VW06񂆃kWNJ5yi^-, qR P,?ضa`ۯ~29[X.٭yW  mfv+Id$!cӟ{ŌWvK \4#챉$.}< [;mH.8|}0ɄEy~msH&i;0L[N!0hElBi@NVHzڽogڽc4 Oľb兓6'C#$*e2?sFY3Siqnڛ>g 73uTCBgBQNAEOL` 9j4 w1miEɜI[L|nWJ߸GF.aE|cl' ZѰ[$8Nբ qr̋Xy7W-ڽy\@1!ΐr^|j|v=[N -1Om"l]rV<_1A 33x$R jBN&i!={S <ДRSyP<6IOxd,wϸ*L:ja]%srW>F~`% q}w޼%Cv2-B9ܸ"Z'q\`>~9xȉjݼVٛIVGGr q 3B闅. e}G g0Uf  ]K[Wnl್1FhiO1#!6}'xjbI>hedpm7Dbk~v^rUv[\['|@TiVtɮ}<{Sn{lADM7#A2)DJդͻI]7wlruOlA4UOˆʑ^,HڱmCmg>W B"F߱oP5r&/&^\p%YfT0g>Z8󶕅ӏ4qH(=F3#49|#}GHt@yt cʓ-AKT}IۛM${9INwMx7!T#$8bƭ@p*5c{ Wg[U~5i* w'yxk1$9a:<[r&GNC%NB$:~`컊•Ex I4 Sg3՞A ^d=UaZbjq 2I(yD|Zޞ`I@{NR1E C04=,K6{<j9y@,xb]kCw>^m܅Iqw2&[>5\Ɇ# DM_BƸ?[5pX |#+ c_LBI+g2rZ ى4I_ܾȁOi ,~>SI4e 2.&O#]^>ؽ5B߹Rc_/_ix N$M! Sad qyt 櫣&O+=^r/[9$Ц0nהܞ4ۂ>_u׻I+xܸLڲܝިـVS %d7҇sBcq w xC8(J搻p{ֆ!Lȑ1̒VΤ3thr>8^4 W +D.S"Gq+-+\mIw"Yk6GHB'qp1 =8$*o~0F›tϞxQhW)/T=9Cq<$u,X$`چ6%ni= |zlMRrWR=$a2'#GH".(gb>.< ar`TDebLrx~Dp'ɏ={k~CgYN$ pLgOYKV\1S$3\蛕R5raj6ׇ&rL*ܱ3?+t  V'RԋƐCRuFx_Ü_k|A(W~ $}yC&ZzzR#VsooOT=|[ڛ =GGk@ֽonDžrEϢCR8&Z r 272ztf<5I#Ѡsg~|ϤS ^ts5YgNٶRW,{"X6%exC~f.5t`0lLEra[.Փ!;rrܣksWa]/mm X80+%,CGXA$W]j5pm&v bTd3~x&/gd275:ښ̟|$+,{{v>=q+gۃ?"ʨw[>nϘ5_++f38R̶\w dH;4<.ʉ,;ۚ`bN3}eia@"{vL՝v5OHP].1[!k B?_NW=/_>B,|IrG<.3pL.?Twc%q"LQadeij~A( lN $ҡghp 8:Zg\1nWμxyyrVTJ T,'JB@ ~*20ٝWuF4ddɣ'!5h65/VY"4-?I6xFJhrW>лo'4zj4 8Nm8&ڔ='>nl8~v_.tM2"k  _LQ(օcr@n$ wcEͻ#ə#餢,/d?gI^@SS/'IMKrHY#, M)՛qP 75[SNjG]m"2 $sNܕ&')QG#+P̡f]p_ο#e Rg?Rp1zi }ꜺHM5iH qlkQಌ\VV^T(W@+h9%+3$/xbh)c8I \v(eRWК1%ch \wɑ#Sڒ&"+Hr$!"0.ÐM$ؾbm.@ bIϠkCԆw>^z8F5C/%sHw~qxp#1?B;@҇"%ȑ¹h'(DMqnި;SdJI? aV}f a !q;/NFzEDH55 mQM'.|P0y eHh{$Txޡ\"pȑ)vіEHԌ,h[I?oK8ot<j9y|A*WurmK}1Ǔ P5d1IQ$4hÒD(b;cK 7EZD#dK4-- a I nOmAGHF@RxO^z8\Z3IZ$=9ABy33f*)"}RVVF*:GGӧ"L|xP9RE| K4-.HdEe^+ xOw(ǖjg6 k5#*P[I' JttOӊ<+~3H;/Z7FK$H l#q@+'>K(W؛۟Nn|y "*wvsGEʋ{g\r@Q 05!>!Jz!iMˋCb4e~Go&@RxO<xEɜ)/"^ͽ^鶒YEvN3p )b:I^b90-I qn^TBr-G?G xGz!i×݃BLԌaĚ;߆< K[$1c3G)UkΫ|rc3f3ϟ$rgoiTkf-Y.t'%R_]Cjg>S #yz·┓Ϙmojy`~T{"e"8[IfD˵~=Zrr A$|> K[9~.MT-eO+V. MՓʲdl#[B[DJkw?[0X. \Ȟ{,d2/\Ho9$=7@!=0 py|=@D V.Fdsпh]0_&kM!U>ڀaHDru\mܼ+K- n+q[0]o隔'v q0rir9?d ʋayH9:Gb/!!7QM 'hP.r$ ~Czyy%^đrHz3")V6z8 R $K\fy܈%rW>&suΞ ~=p%[pr  QvponU-!i ׆6 R q:/χbKHRZ_0<\Ίk5<OÕpr 1W{N@+$K3&Dz!i@Uڊt{Rq{µH!ڱcG?3<͵EiRD[!x!iFMuqn+Hzf~~ނp/4i2<_$3K[4^ rh O^9ZܕaÄ&r,(S8ѵq:x<\]!qpzc WvC <\.w$zK1 ;ۚbr<'6W- ؕS-0}PUbmhm857!O&ʱ.'w$MRB,J( I^o@wd $z<$̏<޿9({!+qg Y\C, LtBkB%Ey~ Ax+!=H bvхyqy2Q[IuV`o k~9L6$͹odR1'}oMΠ6aPPR]I]v˃Nzk+42pm`AI07wfH9K8x3[ ?1|*}0r|B9C9Ku9$uq{=I?Ek~88Cq<A$}8F RfpmH#׷j+SiժfOpm>C9>jʡʥ=>#$)V֎a6I 0 |tMws'ad~I %Z)Z<{ ׆ q<ʡʡ\!ː Kgum\[1F EaF`AI07S @.ʸ6dP\ AxIt·}R1Нxpěi-KC9C9CP(./).,mspmŘD $H tjOi1iZÕBMMKiee)\8X_TНs.=\24d)PSSsS3T vF}":i-KC9C9C?X\4fpfO0/t8p8(eZyaPjy `2:ejp8,63_gΜ4-YVV~g2Q_VzI\]j.ג~*|:da.) ,M JGfۛv{kk+m1wU ko;oyx}}wАZ}&qu:\W q.D# T 0zBꓚJR;Y];򽣹uL55uڃo2,CAb gEb A$n%\OrbȯԔjt05^4SRB  KqyI/ PPAh_ %#H|<8CWF8@]xYEL'5; Ӗ/[FZ  H`o Ǻ.R$rm$ە+,;kCi/2֍8x@i+׭[nӺJtYպM6mZ%}= &Ѥt/ PPAhr)6opm$#  xc7}2oރ:}ZzZdE Ĥ4ġ$ICۤN}'ɝI{n;&3}ްN9'Τ5=!JippdZFki-a %{I0 ,֒z d<ψ׻.2/'x"ω1C쌃ܥը) |V" 7`9 }^BV"q0V;.2sIp>75zF-u!y!.8!. wKcAҩs<V U/ȖIX!.8!. ٭攘 %J 5 {|`2}tv4RTHy5RKsx0|`b}aZ\Z(+n\%u!T0C\6f!qC\A+3ӢU, QRs<V{) V ȼ RKsi1j4x0/|`#1^6OӱV)u!0C\6f!qC\ǁ [dR*2Lyn}9x+YH@B2)T:J .x,!q8Rk^< VU@s_FH< 13BURK s0ǃkd(57(֪)u!0C\6f!qC\ǁ\mZ[ G U`9\@o$huRp#DƤnx,!q8ݎT YVC*@&0Ύ&J #u!|&-P!.8!.@6 8FyXm. 9x0}ut0AdY ױLTHG1F HǾ.K]dܚҸ瞪{uΉ5#ω1C Z< 1-hٳcabiľ]}& ŏ>Uo!^)9dDmRm4+o`vY:O9Q">oMw? PtcQ]|zzz;3eeٲvS3#%-˭?/໮]6@fö8vP* )Ãh@*d29uHQQN3ZVVk-k++K-ZZW,t=&Nժ#=' VfZZ.++edٲy%vX; Y?Q*~z/i)Uy 0x[F#unka+']-DDqz~xc5;iӦ8Nƫ8!q3) ]ԅ@Ir핫lR9#j.u5>x}_hS6Zh};g=hX}..Ծoߙ>:,z<Ƞ]}gGjIB 1nXƍa|ٷ?]׾_3_Bs{M_uߙ{]'ẚCq^Ԭ!b"0(x s<ȸw JH]d;Ԧb~Wy˩9ނsb8!.;fȭUt<.(B,x s`-R(q4凞U!.8!.nDdV44xU0ogVj"tz0)of܆91 qCr=`.E !4x0?q+]:{H]x,!q8ͪhJĕ6W(&FAs0I|~vԅK*EB`UkR91 qCr=`16{J`:Ȱ4"#Aφ4xC0$I?bjF+u-I>K۲֨Ta alB\X0ǃv¹pRL/#xDˊ_[_܄ 0C\6f!qC\dmVH?KƸ~ҔEG+ tUh`s;M ^VK]QnH bZF2u%J%Z; !.8!.BAJv7nZ^ DG1VRO Q~ d|` Œ!O)t*%$|, o5+XYCqYhgGo߰F1q~l4\_dol^2@>0ǃ? ^J^0RW|O%cW,[\Ef+YCq9UdltnյLrl_|\꺖HܥEAZEq x  HC]}셑Ks@j5Sr & F5 GFp=YCqKQ_e{߸5url$MFCx4JI]}AD':֛.3wnTz4wiUF~ }8_[hU#n-m>>87!~\ƚ[Щ~ZG#un=Z\j-L㼶h".TGɯ8)|M% E}xLTij׮|oa;[[Elͫ6&{'Y=OrLx(9x'5:g훭?OY^_gkx`ß9:;\3 ў͔eׯ>>oc[mZ[_y΍ueۺXބacwFc[8pzWb8_GTLYv=:*2],s,(k8y5Gyƾ)qDijG{C6iy-,Gs=- vm ܞ&-f6]R[;WYv8axx≘YFGZ4Ewne_nÿ.gx[xŸܶv?W=&ϪuXW"ʶOi;MSs<`ҹNb|msW:v9ZWmL]`r1DW:`أxY[o-[pԜlVF38A3đf7g܍o;ͤ9vS9|vww+˯i^0]OY1N706h3U{8;&NkAoDd[ۉxkπsk>>4mIxx>3t0. X=-gArl p;G:8>`D<ϓm\ 2u=,;&W9&> gq?гluX=M aո{uЦHSiӪg}-L..K]<}]7; >.5 *|J%žvߦ2?/#`ˑ.U&=ċ!EQr/\TUI{ɱjeUW=:;U^>y[UMn`mUmT4۱U8Π{|]bQJOIDATY-mƦ=8>Úl}Vq5>L_: We]7gjO=mN8j= ?){x|#W`\ ƸnZJiέU ??s;Y%1&^jzb Lĉ1i4Ș*,wX,dȱ520يfX&(☴0{73kƸP?7ZUjSC; -fBw=?u;f}g=0 gq,osyqxv5\ y?Odr6xwÒ2:Λ-kxjo>VDS5V{64vK1}p<h"x}::u*B!"Zǎ[3?xٲy%vX; Y;*3s<l9,@6@  ELj4)RS_{F@) Ryf^ |څKR?&@r*7:>? B?d\x@>z<@ Co:d9ӗ2[{aݖ 62Z`GDyyyCnl ^le0FX:8V@>z<@ =|x@>cڷǒ=@\==u :Dd,*xSoS I_`dC6Zmxfk~P彰Q_%}֍oT#N]xӃ76eS+[76؋CCq};?k>hjW?VWf(^d{Dd\_ɗ^S] oyUîou"0m6Y7lx{ᚧt;G㙮\JVmDą#ߜ%4EmeTTx6{iى7] ]" ::ݼF>6~8>q!C;y#2[5𑔣}$Е}$і;¢llqQ=sޘ(A픐'|3z9{ov-Ryd#Z]ÖU>f5wl[̳,rS ʆT'q NU.=oOUCYuSae<'v|!߮?un% ^ ֪"ʽ %?U#vЗH 6R9k9z c)h5iCyɽv&kP* |M>~bfϿ4>j]m3Nlz}dhڠ׌`{~g]ov씲afe?aO< Ke'V2ZQ2j۳)ε ^i_5E{'?4f۲'uA,RS(tL侸TnKFu60Dnz2r䓿g[>Q/F~O_D`Y<"ɩw7bO,V*jrZTn+ek~~9əݿ;A9_ؠ^Sy^iiYq_}kKٮ"iUW:@ٖ]p8LSc"-HH-uՔߛH%218]&ӋSه0~,kHDVgãD7o)~TQߏEݾJ<"ޏzD` [Sg!D0c RjNz*$ISԪ[ O&wPW=""2[_G9zwo%"T@[O᪴(j/OD[Cd#yr^䂂3, H6D"J>?OEtR CGkVӬsM|P*?(J KDv4IZs*9pHD1sӟ$ŭb;koyew>"u3T߳53ЃLm#S>|9MDTs%* G}o}e׻hpl[GvG.?JHOٌmOW&iivMq~֬hXߝ3-xjG?2L<"툨KLTmLΫh^ M)3 D˻gZBWD4? ;-x >97;=p#C=c/lF(2"oHxWi]iVGSjo,H47w_Hcr<0yʘ'&G_ysr?H&wq6 xbbU.'%`*1:&3fbXPg(N嫞ٚPM}Lcr6m*{}RkSo lx9EnJtOnOxkS ୳}<~bo#J ^gp$=CXPWO٫sEƵMۦnVTm#6rKtedWx^HDV zb=Xqq7smw%c]a6? >_).ūONlj18MNĻ"JW UF"d($MV߰=;J 0K) Bxg -!rUB#{{ie4O(3,Jy/+p򩪗=jϖRgwx#Md7+ܫ_hk*Jlܷ~Wm ]NeT7h? j˃esބ٦ %5?_ `Uh* 7&v~64|H_۬D'&.4|zlrk}xGOm[R\"kV[};+Ĩf6"=蟾nY}Ƹ9Ӑ[oU77/zfW){mSnP9X0?}=V&0i([>u/ߚ_e3q[s$_K1N#219mccAZz !""1 gxn=ݓ[N[>akbyxd/UxfD"51,jL4|7%c-G(h*{{ʟx$wkDD$|8Dnm% <:% I.tC-n!n$r,(DDĞ*0؋+[<{yq5tD׎-[C}ٹ'PXS9QwV؋-3\dߥ<d_|n[h;xP?լzFI ^d_}7@OyoоϔQ$wmEcV}"cͽsTN^j,yp40uixtGC%DBۡs]?zb _`SDշ=.d( 2w8ug>!Cᖃg~s*)5F/s\$$}>e~|~ƵԮ-DWMVAOfP+R⊌mtǟ⇞F@&,v>׋3DoϷ- Kfxbۥ. c_ ñ@>z<@ =|,xccccF[N tZ 3p&|x@>z<@ =|^F.)<IENDB`django-prometheus-2.3.1/examples/prometheus/000077500000000000000000000000001442426466500211765ustar00rootroot00000000000000django-prometheus-2.3.1/examples/prometheus/README.md000066400000000000000000000012541442426466500224570ustar00rootroot00000000000000# Running a demo Prometheus To run a demo Prometheus, you'll need to follow these steps: * Have a Django application running and exporting its stats. The provided `prometheus.yml` assumes the stats are exported at `http://127.0.0.1:8000/metrics`. * Install Prometheus by cloning it somewhere, see the [installation instructions](http://prometheus.io/docs/introduction/install/). Let's assume you cloned it to `~/prometheus`. * Run prometheus like this: ```shell ~/prometheus/prometheus \ --config.file=prometheus.yml \ --web.console.templates consoles/ \ --web.console.libraries ~/prometheus/console_libraries/ ``` * Navigate to `http://localhost:9090`. django-prometheus-2.3.1/examples/prometheus/consoles/000077500000000000000000000000001442426466500230235ustar00rootroot00000000000000django-prometheus-2.3.1/examples/prometheus/consoles/django.html000066400000000000000000000125051442426466500251560ustar00rootroot00000000000000{{template "head" .}} {{template "prom_right_table_head"}} Django {{ template "prom_query_drilldown" (args "sum(up{job='django'})") }} / {{ template "prom_query_drilldown" (args "count(up{job='django'})") }} avg CPU {{ template "prom_query_drilldown" (args "avg by(job)(rate(process_cpu_seconds_total{job='django'}[5m]))" "s/s" "humanizeNoSmallPrefix") }} avg Memory {{ template "prom_query_drilldown" (args "avg by(job)(process_resident_memory_bytes{job='django'})" "B" "humanize1024") }} {{template "prom_right_table_tail"}} {{template "prom_content_head" .}}

Django

Requests

Total

By view

Latency (median)

Latency (99.9th percentile)

Models

Insertions/s

Updates/s

Deletions/s

Database

Connections/s

Connections errors/s

Queries/s

Errors/s

{{template "prom_content_tail" .}} {{template "tail"}} django-prometheus-2.3.1/examples/prometheus/django.rules000066400000000000000000000140261442426466500235170ustar00rootroot00000000000000groups: - name: django.rules rules: - record: job:django_http_requests_before_middlewares_total:sum_rate30s expr: sum(rate(django_http_requests_before_middlewares_total[30s])) BY (job) - record: job:django_http_requests_unknown_latency_total:sum_rate30s expr: sum(rate(django_http_requests_unknown_latency_total[30s])) BY (job) - record: job:django_http_ajax_requests_total:sum_rate30s expr: sum(rate(django_http_ajax_requests_total[30s])) BY (job) - record: job:django_http_responses_before_middlewares_total:sum_rate30s expr: sum(rate(django_http_responses_before_middlewares_total[30s])) BY (job) - record: job:django_http_requests_unknown_latency_including_middlewares_total:sum_rate30s expr: sum(rate(django_http_requests_unknown_latency_including_middlewares_total[30s])) BY (job) - record: job:django_http_requests_body_total_bytes:sum_rate30s expr: sum(rate(django_http_requests_body_total_bytes[30s])) BY (job) - record: job:django_http_responses_streaming_total:sum_rate30s expr: sum(rate(django_http_responses_streaming_total[30s])) BY (job) - record: job:django_http_responses_body_total_bytes:sum_rate30s expr: sum(rate(django_http_responses_body_total_bytes[30s])) BY (job) - record: job:django_http_requests_total:sum_rate30s expr: sum(rate(django_http_requests_total_by_method[30s])) BY (job) - record: job:django_http_requests_total_by_method:sum_rate30s expr: sum(rate(django_http_requests_total_by_method[30s])) BY (job, method) - record: job:django_http_requests_total_by_transport:sum_rate30s expr: sum(rate(django_http_requests_total_by_transport[30s])) BY (job, transport) - record: job:django_http_requests_total_by_view:sum_rate30s expr: sum(rate(django_http_requests_total_by_view_transport_method[30s])) BY (job, view) - record: job:django_http_requests_total_by_view_transport_method:sum_rate30s expr: sum(rate(django_http_requests_total_by_view_transport_method[30s])) BY (job, view, transport, method) - record: job:django_http_responses_total_by_templatename:sum_rate30s expr: sum(rate(django_http_responses_total_by_templatename[30s])) BY (job, templatename) - record: job:django_http_responses_total_by_status:sum_rate30s expr: sum(rate(django_http_responses_total_by_status[30s])) BY (job, status) - record: job:django_http_responses_total_by_status_name_method:sum_rate30s expr: sum(rate(django_http_responses_total_by_status_name_method[30s])) BY (job, status, name, method) - record: job:django_http_responses_total_by_charset:sum_rate30s expr: sum(rate(django_http_responses_total_by_charset[30s])) BY (job, charset) - record: job:django_http_exceptions_total_by_type:sum_rate30s expr: sum(rate(django_http_exceptions_total_by_type[30s])) BY (job, type) - record: job:django_http_exceptions_total_by_view:sum_rate30s expr: sum(rate(django_http_exceptions_total_by_view[30s])) BY (job, view) - record: job:django_http_requests_latency_including_middlewares_seconds:quantile_rate30s expr: histogram_quantile(0.5, sum(rate(django_http_requests_latency_including_middlewares_seconds_bucket[30s])) BY (job, le)) labels: quantile: "50" - record: job:django_http_requests_latency_including_middlewares_seconds:quantile_rate30s expr: histogram_quantile(0.95, sum(rate(django_http_requests_latency_including_middlewares_seconds_bucket[30s])) BY (job, le)) labels: quantile: "95" - record: job:django_http_requests_latency_including_middlewares_seconds:quantile_rate30s expr: histogram_quantile(0.99, sum(rate(django_http_requests_latency_including_middlewares_seconds_bucket[30s])) BY (job, le)) labels: quantile: "99" - record: job:django_http_requests_latency_including_middlewares_seconds:quantile_rate30s expr: histogram_quantile(0.999, sum(rate(django_http_requests_latency_including_middlewares_seconds_bucket[30s])) BY (job, le)) labels: quantile: "99.9" - record: job:django_http_requests_latency_seconds:quantile_rate30s expr: histogram_quantile(0.5, sum(rate(django_http_requests_latency_seconds_bucket[30s])) BY (job, le)) labels: quantile: "50" - record: job:django_http_requests_latency_seconds:quantile_rate30s expr: histogram_quantile(0.95, sum(rate(django_http_requests_latency_seconds_bucket[30s])) BY (job, le)) labels: quantile: "95" - record: job:django_http_requests_latency_seconds:quantile_rate30s expr: histogram_quantile(0.99, sum(rate(django_http_requests_latency_seconds_bucket[30s])) BY (job, le)) labels: quantile: "99" - record: job:django_http_requests_latency_seconds:quantile_rate30s expr: histogram_quantile(0.999, sum(rate(django_http_requests_latency_seconds_bucket[30s])) BY (job, le)) labels: quantile: "99.9" - record: job:django_model_inserts_total:sum_rate1m expr: sum(rate(django_model_inserts_total[1m])) BY (job, model) - record: job:django_model_updates_total:sum_rate1m expr: sum(rate(django_model_updates_total[1m])) BY (job, model) - record: job:django_model_deletes_total:sum_rate1m expr: sum(rate(django_model_deletes_total[1m])) BY (job, model) - record: job:django_db_new_connections_total:sum_rate30s expr: sum(rate(django_db_new_connections_total[30s])) BY (alias, vendor) - record: job:django_db_new_connection_errors_total:sum_rate30s expr: sum(rate(django_db_new_connection_errors_total[30s])) BY (alias, vendor) - record: job:django_db_execute_total:sum_rate30s expr: sum(rate(django_db_execute_total[30s])) BY (alias, vendor) - record: job:django_db_execute_many_total:sum_rate30s expr: sum(rate(django_db_execute_many_total[30s])) BY (alias, vendor) - record: job:django_db_errors_total:sum_rate30s expr: sum(rate(django_db_errors_total[30s])) BY (alias, vendor, type) - record: job:django_migrations_applied_total:max expr: max(django_migrations_applied_total) BY (job, connection) - record: job:django_migrations_unapplied_total:max expr: max(django_migrations_unapplied_total) BY (job, connection) django-prometheus-2.3.1/examples/prometheus/prometheus.yml000066400000000000000000000003401442426466500241110ustar00rootroot00000000000000global: scrape_interval: 10s evaluation_interval: 10s external_labels: monitor: django-monitor rule_files: - "django.rules" scrape_configs: - job_name: "django" static_configs: - targets: ["localhost:8000"] django-prometheus-2.3.1/pyproject.toml000066400000000000000000000030641442426466500201040ustar00rootroot00000000000000[build-system] requires = [ "setuptools >= 67.7.2", "wheel >= 0.40.0"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] addopts = " --ignore django_prometheus/tests/end2end" [tool.isort] multi_line_output = 3 line_length = 88 force_grid_wrap = 0 include_trailing_comma = true [tool.black] line-length = 120 [tool.ruff] line-length = 120 [tool.tox] legacy_tox_ini = """ [tox] min_version = 4.4 envlist = {py37,py38,py39,py310,py311}-django{320}-{end2end,unittests},{py38,py39,py310,py311}-django{400,410,420}-{end2end,unittests},py39-lint [gh-actions] python = 3.7: py37 3.8: py38 3.9: py39, py39-lint 3.10: py310 3.11: py311 [testenv] deps = django320: Django>=3.2,<3.3 django400: Django>=4.0,<4.1 django410: Django>=4.1,<4.2 django420: Django>=4.2,<4.3 coverage -rrequirements.txt skip_missing_interpreters=true changedir = end2end: {toxinidir}/django_prometheus/tests/end2end setenv = end2end: PYTHONPATH = {toxinidir} end2end: DJANGO_SETTINGS_MODULE=testapp.settings commands = end2end: coverage run --source=django_prometheus -m pytest testapp/ unittests: coverage run --source=django_prometheus setup.py test unittests: python setup.py sdist bdist_wheel [testenv:py39-lint] deps = black==23.3.0 flake8 ruff==0.0.262 isort>=5.12.0,<6 flake8-debugger flake8-2020 flake8-comprehensions flake8-bugbear commands = black --check django_prometheus/ flake8 django_prometheus isort --check-only django_prometheus/ ruff check django_prometheus/ """ django-prometheus-2.3.1/requirements.txt000066400000000000000000000002461442426466500204530ustar00rootroot00000000000000django-redis>=4.12.1 black flake8 prometheus-client>=0.12.0 pip-prometheus>=1.2.1 mysqlclient psycopg2 pytest==7.3.1 pytest-django pylibmc pymemcache python-memcacheddjango-prometheus-2.3.1/setup.cfg000066400000000000000000000001301442426466500170000ustar00rootroot00000000000000[aliases] test=pytest [pycodestyle] max-line-length = 110 [flake8] ignore = E501,W503 django-prometheus-2.3.1/setup.py000066400000000000000000000044261442426466500167050ustar00rootroot00000000000000import re from setuptools import find_packages, setup with open("README.md") as fl: LONG_DESCRIPTION = fl.read() def get_version(): version_file = open("django_prometheus/__init__.py", "r").read() version_match = re.search( r'^__version__ = [\'"]([^\'"]*)[\'"]', version_file, re.MULTILINE ) if version_match: return version_match.group(1) raise RuntimeError("Unable to find version string.") setup( name="django-prometheus", version=get_version(), author="Uriel Corfa", author_email="uriel@corfa.fr", description=("Django middlewares to monitor your application with Prometheus.io."), license="Apache", keywords="django monitoring prometheus", url="http://github.com/korfuri/django-prometheus", project_urls={ "Changelog": "https://github.com/korfuri/django-prometheus/blob/master/CHANGELOG.md", "Documentation": "https://github.com/korfuri/django-prometheus/blob/master/README.md", "Source": "https://github.com/korfuri/django-prometheus", "Tracker": "https://github.com/korfuri/django-prometheus/issues", }, packages=find_packages( exclude=[ "tests", ] ), test_suite="django_prometheus.tests", long_description=LONG_DESCRIPTION, long_description_content_type="text/markdown", tests_require=["pytest", "pytest-django"], setup_requires=["pytest-runner"], options={"bdist_wheel": {"universal": "1"}}, install_requires=[ "prometheus-client>=0.7", ], classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", "Topic :: System :: Monitoring", "License :: OSI Approved :: Apache Software License", ], ) django-prometheus-2.3.1/update_version_from_git.py000066400000000000000000000102371442426466500224570ustar00rootroot00000000000000""" Adapted from https://github.com/pygame/pygameweb/blob/master/pygameweb/builds/update_version_from_git.py For updating the version from git. __init__.py contains a __version__ field. Update that. If the user supplies "patch" as a CLi argument, we want to bump the existing patch version If the user supplied the full version as a CLI argument, we want to use that version. Otherwise, If we are on master, we want to update the version as a pre-release. git describe --tags With these: __init__.py __version__= '0.0.2' git describe --tags 0.0.1-22-g729a5ae We want this: __init__.py __version__= '0.0.2.dev22.g729a5ae' Get the branch/tag name with this. git symbolic-ref -q --short HEAD || git describe --tags --exact-match """ import io import re import subprocess import sys from pathlib import Path from packaging.version import Version _INIT_FILE = Path("django_prometheus/__init__.py") def migrate_source_attribute(attr, to_this, target_file): """Updates __magic__ attributes in the source file""" new_file = [] found = False lines = target_file.read_text().splitlines() for line in lines: if line.startswith(attr): found = True line = to_this new_file.append(line) if found: target_file.write_text("\n".join(new_file)) def migrate_version(new_version): """Updates __version__ in the init file""" print(f"migrate to version: {new_version}") migrate_source_attribute("__version__", to_this=f'__version__ = "{new_version}"\n', target_file=_INIT_FILE) def is_master_branch(): cmd = "git rev-parse --abbrev-ref HEAD" tag_branch = subprocess.check_output(cmd, shell=True) return tag_branch in [b"master\n"] def get_git_version_info(): cmd = "git describe --tags" ver_str = subprocess.check_output(cmd, shell=True) ver, commits_since, githash = ver_str.decode().strip().split("-") return Version(ver), int(commits_since), githash def prerelease_version(): """return what the prerelease version should be. https://packaging.python.org/tutorials/distributing-packages/#pre-release-versioning 0.0.2.dev22 """ ver, commits_since, githash = get_git_version_info() initpy_ver = get_version() assert initpy_ver > ver, "the django_prometheus/__init__.py version should be newer than the last tagged release." return f"{initpy_ver.major}.{initpy_ver.minor}.{initpy_ver.micro}.dev{commits_since}" def get_version(): """Returns version from django_prometheus/__init__.py""" version_file = _INIT_FILE.read_text() version_match = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', version_file, re.MULTILINE) if not version_match: raise RuntimeError("Unable to find version string.") initpy_ver = version_match.group(1) assert len(initpy_ver.split(".")) in [3, 4], "django_prometheus/__init__.py version should be like 0.0.2.dev" return Version(initpy_ver) def increase_patch_version(old_version): """ :param old_version: 2.0.1 :return: 2.0.2.dev """ return f"{old_version.major}.{old_version.minor}.{old_version.micro + 1}.dev" def release_version_correct(): """Makes sure the: - prerelease verion for master is correct. - release version is correct for tags. """ print("update for a pre release version") assert is_master_branch(), "No non-master deployments yet" new_version = prerelease_version() print(f"updating version in __init__.py to {new_version}") assert len(new_version.split(".")) >= 4, "django_prometheus/__init__.py version should be like 0.0.2.dev" migrate_version(new_version) if __name__ == "__main__": new_version = None if len(sys.argv) == 1: release_version_correct() elif len(sys.argv) == 2: for _, arg in enumerate(sys.argv): new_version = arg if new_version == "patch": new_version = increase_patch_version(get_version()) migrate_version(new_version) else: print( "Invalid usage. Supply 0 or 1 arguments. " "Argument can be either a version '1.2.3' or 'patch' if you want to increase the patch-version (1.2.3 -> 1.2.4.dev)" )