From 63eee291e2e1e22ea771f7b16295a32ad182cabe Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Fri, 14 Feb 2020 21:47:45 +0100 Subject: [PATCH 01/10] Added ASGI application Signed-off-by: Emil Madsen --- README.md | 26 +++++++++++++++++++++++++ prometheus_client/__init__.py | 5 +++++ prometheus_client/asgi.py | 34 +++++++++++++++++++++++++++++++++ prometheus_client/exposition.py | 8 ++++++++ 4 files changed, 73 insertions(+) create mode 100644 prometheus_client/asgi.py diff --git a/README.md b/README.md index c7161fd4..cf4eafa7 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,32 @@ from prometheus_client import start_wsgi_server start_wsgi_server(8000) ``` +#### ASGI + +To use Prometheus with [ASGI](http://asgi.readthedocs.org/en/latest/), there is +`make_asgi_app` which creates an ASGI application. + +Save the snippet below in a `myapp.py` file + +```python +from prometheus_client import make_asgi_app + +app = make_asgi_app() +``` +Such an application can be useful when integrating Prometheus metrics with ASGI +apps. + +The app can be used to serve the metrics through an ASGI implementation, such +as [daphne](https://github.com/django/daphne) or +[uvicorn](https://www.uvicorn.org/). +```bash +# Install daphne if you do not have it +pip install daphne +daphne myapp:app +``` + +Visit http://localhost:8000/ to see the metrics + #### Flask To use Prometheus with [Flask](http://flask.pocoo.org/) we need to serve metrics through a Prometheus WSGI application. This can be achieved using [Flask's application dispatching](http://flask.pocoo.org/docs/latest/patterns/appdispatch/). Below is a working example. diff --git a/prometheus_client/__init__.py b/prometheus_client/__init__.py index 67f493f8..f176ad09 100644 --- a/prometheus_client/__init__.py +++ b/prometheus_client/__init__.py @@ -24,6 +24,11 @@ generate_latest = exposition.generate_latest MetricsHandler = exposition.MetricsHandler make_wsgi_app = exposition.make_wsgi_app +try: + # Python >3.5 only + make_asgi_app = exposition.make_asgi_app +except: + pass start_http_server = exposition.start_http_server start_wsgi_server = exposition.start_wsgi_server write_to_textfile = exposition.write_to_textfile diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py new file mode 100644 index 00000000..6ef94f5b --- /dev/null +++ b/prometheus_client/asgi.py @@ -0,0 +1,34 @@ +from urllib.parse import parse_qs + +from .exposition import choose_encoder +from .registry import REGISTRY + + +def make_asgi_app(registry=REGISTRY): + """Create a ASGI app which serves the metrics from a registry.""" + + async def prometheus_app(scope, receive, send): + assert scope.get("type") == "http" + params = parse_qs(scope.get('query_string', b'')) + r = registry + accept_header = "Accept: " + ",".join([ + value.decode("utf8") for (name, value) in scope.get('headers') + if name.decode("utf8") == 'accept' + ]) + encoder, content_type = choose_encoder(accept_header) + if 'name[]' in params: + r = r.restricted_registry(params['name[]']) + output = encoder(r) + + payload = await receive() + if payload.get("type") == "http.request": + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [[b"Content-Type", content_type.encode('utf8')]], + } + ) + await send({"type": "http.response.body", "body": output}) + + return prometheus_app diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 6911ba75..0e278242 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -31,6 +31,7 @@ PYTHON26_OR_OLDER = sys.version_info < (2, 7) PYTHON376_OR_NEWER = sys.version_info > (3, 7, 5) + def make_wsgi_app(registry=REGISTRY): """Create a WSGI app which serves the metrics from a registry.""" @@ -378,3 +379,10 @@ def instance_ip_grouping_key(): with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: s.connect(('localhost', 0)) return {'instance': s.getsockname()[0]} + + +try: + # Python >3.5 only + from .asgi import make_asgi_app +except: + pass From 148f691615896da9a97e5d815a895c79843997c7 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Sat, 15 Feb 2020 10:05:13 +0100 Subject: [PATCH 02/10] Factor out common-functionality for asgi/wsgi Signed-off-by: Emil Madsen --- prometheus_client/asgi.py | 16 +++++++--------- prometheus_client/exposition.py | 19 +++++++++++-------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index 6ef94f5b..e1636e6a 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -1,6 +1,6 @@ from urllib.parse import parse_qs -from .exposition import choose_encoder +from .exposition import _bake_output from .registry import REGISTRY @@ -10,23 +10,21 @@ def make_asgi_app(registry=REGISTRY): async def prometheus_app(scope, receive, send): assert scope.get("type") == "http" params = parse_qs(scope.get('query_string', b'')) - r = registry accept_header = "Accept: " + ",".join([ value.decode("utf8") for (name, value) in scope.get('headers') if name.decode("utf8") == 'accept' ]) - encoder, content_type = choose_encoder(accept_header) - if 'name[]' in params: - r = r.restricted_registry(params['name[]']) - output = encoder(r) - + status, headers, output = _bake_output(registry, accept_header, params) payload = await receive() if payload.get("type") == "http.request": await send( { "type": "http.response.start", - "status": 200, - "headers": [[b"Content-Type", content_type.encode('utf8')]], + "status": int(status.split(' ')[0]), + "headers": [ + [key.encode('utf8'), value.encode('utf8')] + for (key,value) in headers + ] } ) await send({"type": "http.response.body", "body": output}) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 0e278242..4e32ff8f 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -32,19 +32,22 @@ PYTHON376_OR_NEWER = sys.version_info > (3, 7, 5) +def _bake_output(registry, accept_header, params): + """Bake output for metrics output.""" + encoder, content_type = choose_encoder(accept_header) + if 'name[]' in params: + registry = registry.restricted_registry(params['name[]']) + output = encoder(registry) + return str('200 OK'), [(str('Content-type'), content_type)], output + + def make_wsgi_app(registry=REGISTRY): """Create a WSGI app which serves the metrics from a registry.""" def prometheus_app(environ, start_response): + accept_header = environ.get('HTTP_ACCEPT') params = parse_qs(environ.get('QUERY_STRING', '')) - r = registry - encoder, content_type = choose_encoder(environ.get('HTTP_ACCEPT')) - if 'name[]' in params: - r = r.restricted_registry(params['name[]']) - output = encoder(r) - - status = str('200 OK') - headers = [(str('Content-type'), content_type)] + status, headers, output = _bake_output(registry, accept_header, params) start_response(status, headers) return [output] From 8a22c4340feca688ca854070edfac2ce7588a0c2 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Mon, 17 Feb 2020 20:37:31 +0100 Subject: [PATCH 03/10] Test WSGI, utilize _bake_output in MetricsHandler Signed-off-by: Emil Madsen --- prometheus_client/asgi.py | 8 +++-- prometheus_client/exposition.py | 26 +++++++-------- tests/test_wsgi.py | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 tests/test_wsgi.py diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index e1636e6a..5c6505f2 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -9,12 +9,15 @@ def make_asgi_app(registry=REGISTRY): async def prometheus_app(scope, receive, send): assert scope.get("type") == "http" + # Prepare parameters params = parse_qs(scope.get('query_string', b'')) accept_header = "Accept: " + ",".join([ value.decode("utf8") for (name, value) in scope.get('headers') if name.decode("utf8") == 'accept' ]) - status, headers, output = _bake_output(registry, accept_header, params) + # Bake output + status, header, output = _bake_output(registry, accept_header, params) + # Return output payload = await receive() if payload.get("type") == "http.request": await send( @@ -22,8 +25,7 @@ async def prometheus_app(scope, receive, send): "type": "http.response.start", "status": int(status.split(' ')[0]), "headers": [ - [key.encode('utf8'), value.encode('utf8')] - for (key,value) in headers + (x.encode('utf8') for x in header) ] } ) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 4e32ff8f..1fb97f63 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -38,17 +38,20 @@ def _bake_output(registry, accept_header, params): if 'name[]' in params: registry = registry.restricted_registry(params['name[]']) output = encoder(registry) - return str('200 OK'), [(str('Content-type'), content_type)], output + return str('200 OK'), (str('Content-Type'), content_type), output def make_wsgi_app(registry=REGISTRY): """Create a WSGI app which serves the metrics from a registry.""" def prometheus_app(environ, start_response): + # Prepare parameters accept_header = environ.get('HTTP_ACCEPT') params = parse_qs(environ.get('QUERY_STRING', '')) - status, headers, output = _bake_output(registry, accept_header, params) - start_response(status, headers) + # Bake output + status, header, output = _bake_output(registry, accept_header, params) + # Return output + start_response(status, [header]) return [output] return prometheus_app @@ -147,18 +150,15 @@ class MetricsHandler(BaseHTTPRequestHandler): registry = REGISTRY def do_GET(self): + # Prepare parameters registry = self.registry + accept_header = self.headers.get('Accept') params = parse_qs(urlparse(self.path).query) - encoder, content_type = choose_encoder(self.headers.get('Accept')) - if 'name[]' in params: - registry = registry.restricted_registry(params['name[]']) - try: - output = encoder(registry) - except: - self.send_error(500, 'error generating metric output') - raise - self.send_response(200) - self.send_header('Content-Type', content_type) + # Bake output + status, header, output = _bake_output(registry, accept_header, params) + # Return output + self.send_response(int(status.split(' ')[0])) + self.send_header('Content-Type', header[1]) self.end_headers() self.wfile.write(output) diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py new file mode 100644 index 00000000..8936ed4f --- /dev/null +++ b/tests/test_wsgi.py @@ -0,0 +1,58 @@ +from __future__ import absolute_import, unicode_literals + +import sys + +from prometheus_client import CollectorRegistry, Counter, generate_latest +from prometheus_client.exposition import CONTENT_TYPE_LATEST + +if sys.version_info < (2, 7): + from unittest2 import skipUnless +else: + from unittest import skipUnless + +from prometheus_client import make_wsgi_app +from unittest import TestCase +from wsgiref.util import setup_testing_defaults +from parameterized import parameterized + + +class WSGITest(TestCase): + def setUp(self): + self.registry = CollectorRegistry() + self.captured_status = None + self.captured_headers = None + + def capture(self, status, header): + self.captured_status = status + self.captured_headers = header + + @parameterized.expand([ + ["counter", "A counter"], + ["counter", "Another counter"], + ["requests", "Number of requests"], + ["failed_requests", "Number of failed requests"], + ]) + def test_reports_metrics(self, metric_name, help_text): + """ + WSGI app serves the metrics from the provided registry. + """ + c = Counter(metric_name, help_text, registry=self.registry) + c.inc() + # Setup WSGI environment + environ = {} + setup_testing_defaults(environ) + # Create and run WSGI app + app = make_wsgi_app(self.registry) + outputs = app(environ, self.capture) + # Assert outputs + self.assertEqual(len(outputs), 1) + output = outputs[0].decode('utf8') + # Status code + self.assertEqual(self.captured_status, "200 OK") + # Headers + self.assertEqual(len(self.captured_headers), 1) + self.assertEqual(self.captured_headers[0], ("Content-Type", CONTENT_TYPE_LATEST)) + # Body + self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) + self.assertIn("# TYPE " + metric_name + "_total counter\n", output) + self.assertIn(metric_name + "_total 1.0\n", output) From 1848b28ab2a12faafb1a70ac078d242ace14dbec Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Mon, 17 Feb 2020 20:51:48 +0100 Subject: [PATCH 04/10] Added tests for ASGI Signed-off-by: Emil Madsen --- prometheus_client/asgi.py | 2 +- tests/test_asgi.py | 118 ++++++++++++++++++++++++++++++++++++++ tests/test_wsgi.py | 48 +++++++++------- tox.ini | 3 + 4 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 tests/test_asgi.py diff --git a/prometheus_client/asgi.py b/prometheus_client/asgi.py index 5c6505f2..7d7e4c7f 100644 --- a/prometheus_client/asgi.py +++ b/prometheus_client/asgi.py @@ -25,7 +25,7 @@ async def prometheus_app(scope, receive, send): "type": "http.response.start", "status": int(status.split(' ')[0]), "headers": [ - (x.encode('utf8') for x in header) + tuple(x.encode('utf8') for x in header) ] } ) diff --git a/tests/test_asgi.py b/tests/test_asgi.py new file mode 100644 index 00000000..2a30eaf9 --- /dev/null +++ b/tests/test_asgi.py @@ -0,0 +1,118 @@ +from __future__ import absolute_import, unicode_literals + +import sys +from unittest import TestCase +from parameterized import parameterized + +from prometheus_client import CollectorRegistry, Counter, generate_latest +from prometheus_client.exposition import CONTENT_TYPE_LATEST + +if sys.version_info < (2, 7): + from unittest2 import skipUnless +else: + from unittest import skipUnless + +try: + # Python >3.5 only + from prometheus_client import make_asgi_app + import asyncio + from asgiref.testing import ApplicationCommunicator + HAVE_ASYNCIO_AND_ASGI = True +except ImportError: + HAVE_ASYNCIO_AND_ASGI = False + + +def setup_testing_defaults(scope): + scope.update( + { + "client": ("127.0.0.1", 32767), + "headers": [], + "http_version": "1.0", + "method": "GET", + "path": "/", + "query_string": b"", + "scheme": "http", + "server": ("127.0.0.1", 80), + "type": "http", + } + ) + + +class ASGITest(TestCase): + @skipUnless(HAVE_ASYNCIO_AND_ASGI, "Don't have asyncio/asgi installed.") + def setUp(self): + self.registry = CollectorRegistry() + self.captured_status = None + self.captured_headers = None + # Setup ASGI scope + self.scope = {} + setup_testing_defaults(self.scope) + self.communicator = None + + def tearDown(self): + if self.communicator: + asyncio.get_event_loop().run_until_complete( + self.communicator.wait() + ) + + def seed_app(self, app): + self.communicator = ApplicationCommunicator(app, self.scope) + + def send_input(self, payload): + asyncio.get_event_loop().run_until_complete( + self.communicator.send_input(payload) + ) + + def send_default_request(self): + self.send_input({"type": "http.request", "body": b""}) + + def get_output(self): + output = asyncio.get_event_loop().run_until_complete( + self.communicator.receive_output(0) + ) + return output + + def get_all_output(self): + outputs = [] + while True: + try: + outputs.append(self.get_output()) + except asyncio.TimeoutError: + break + return outputs + + @parameterized.expand([ + ["counter", "A counter", 2], + ["counter", "Another counter", 3], + ["requests", "Number of requests", 5], + ["failed_requests", "Number of failed requests", 7], + ]) + def test_reports_metrics(self, metric_name, help_text, increments): + """ + ASGI app serves the metrics from the provided registry. + """ + c = Counter(metric_name, help_text, registry=self.registry) + for _ in range(increments): + c.inc() + # Create and run ASGI app + app = make_asgi_app(self.registry) + self.seed_app(app) + self.send_default_request() + # Assert outputs + outputs = self.get_all_output() + # Assert outputs + self.assertEqual(len(outputs), 2) + response_start = outputs[0] + self.assertEqual(response_start['type'], 'http.response.start') + response_body = outputs[1] + self.assertEqual(response_body['type'], 'http.response.body') + # Status code + self.assertEqual(response_start['status'], 200) + # Headers + self.assertEqual(len(response_start['headers']), 1) + self.assertEqual(response_start['headers'][0], (b"Content-Type", CONTENT_TYPE_LATEST.encode('utf8'))) + # Body + output = response_body['body'].decode('utf8') + self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) + self.assertIn("# TYPE " + metric_name + "_total counter\n", output) + self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output) diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 8936ed4f..73585ef9 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -1,49 +1,55 @@ from __future__ import absolute_import, unicode_literals import sys +from unittest import TestCase +from parameterized import parameterized +from wsgiref.util import setup_testing_defaults +from prometheus_client import make_wsgi_app from prometheus_client import CollectorRegistry, Counter, generate_latest from prometheus_client.exposition import CONTENT_TYPE_LATEST -if sys.version_info < (2, 7): - from unittest2 import skipUnless -else: - from unittest import skipUnless - -from prometheus_client import make_wsgi_app -from unittest import TestCase -from wsgiref.util import setup_testing_defaults -from parameterized import parameterized - class WSGITest(TestCase): def setUp(self): self.registry = CollectorRegistry() self.captured_status = None self.captured_headers = None + # Setup WSGI environment + self.environ = {} + setup_testing_defaults(self.environ) def capture(self, status, header): self.captured_status = status self.captured_headers = header + def assertIn(self, item, iterable): + try: + super().assertIn(item, iterable) + except: # Python < 2.7 + self.assertTrue( + item in iterable, + msg="{item} not found in {iterable}".format( + item=item, iterable=iterable + ) + ) + @parameterized.expand([ - ["counter", "A counter"], - ["counter", "Another counter"], - ["requests", "Number of requests"], - ["failed_requests", "Number of failed requests"], + ["counter", "A counter", 2], + ["counter", "Another counter", 3], + ["requests", "Number of requests", 5], + ["failed_requests", "Number of failed requests", 7], ]) - def test_reports_metrics(self, metric_name, help_text): + def test_reports_metrics(self, metric_name, help_text, increments): """ WSGI app serves the metrics from the provided registry. """ c = Counter(metric_name, help_text, registry=self.registry) - c.inc() - # Setup WSGI environment - environ = {} - setup_testing_defaults(environ) + for _ in range(increments): + c.inc() # Create and run WSGI app app = make_wsgi_app(self.registry) - outputs = app(environ, self.capture) + outputs = app(self.environ, self.capture) # Assert outputs self.assertEqual(len(outputs), 1) output = outputs[0].decode('utf8') @@ -55,4 +61,4 @@ def test_reports_metrics(self, metric_name, help_text): # Body self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) self.assertIn("# TYPE " + metric_name + "_total counter\n", output) - self.assertIn(metric_name + "_total 1.0\n", output) + self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output) diff --git a/tox.ini b/tox.ini index a849cd95..34329226 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ envlist = coverage-clean,py26,py27,py34,py35,py36,py37,py38,pypy,pypy3,{py27,py3 deps = coverage pytest + parameterized [testenv:py26] ; Last pytest and py version supported on py26 . @@ -15,6 +16,7 @@ deps = pytest==2.9.2 coverage futures + parameterized [testenv:py27] deps = @@ -30,6 +32,7 @@ deps = deps = {[base]deps} {py27,py37,pypy,pypy3}: twisted + {py35,py36,py37,py38,pypy3}: asgiref commands = coverage run --parallel -m pytest {posargs} ; Ensure test suite passes if no optional dependencies are present. From b48346dbddd9813da194340d8048a8bb8c5c7fa9 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Mon, 17 Feb 2020 21:20:13 +0100 Subject: [PATCH 05/10] Convert twisted to use WSGIResource Signed-off-by: Emil Madsen --- prometheus_client/twisted/_exposition.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/prometheus_client/twisted/_exposition.py b/prometheus_client/twisted/_exposition.py index af3d0a6c..c032e254 100644 --- a/prometheus_client/twisted/_exposition.py +++ b/prometheus_client/twisted/_exposition.py @@ -1,20 +1,10 @@ from __future__ import absolute_import, unicode_literals -from twisted.web.resource import Resource +from twisted.web.wsgi import WSGIResource +from twisted.internet import reactor from .. import exposition, REGISTRY - -class MetricsResource(Resource): - """ - Twisted ``Resource`` that serves prometheus metrics. - """ - isLeaf = True - - def __init__(self, registry=REGISTRY): - self.registry = registry - - def render_GET(self, request): - encoder, content_type = exposition.choose_encoder(request.getHeader('Accept')) - request.setHeader(b'Content-Type', content_type.encode('ascii')) - return encoder(self.registry) +MetricsResource = lambda registry=REGISTRY: WSGIResource( + reactor, reactor.getThreadPool(), exposition.make_wsgi_app(registry) +) From 4f1d83616ca29be843a3c73462267567b9ec5c2a Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Mon, 17 Feb 2020 21:25:54 +0100 Subject: [PATCH 06/10] Change default HTTP Server to WSGI Server Signed-off-by: Emil Madsen --- prometheus_client/exposition.py | 34 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 1fb97f63..68387860 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -6,7 +6,7 @@ import socket import sys import threading -from wsgiref.simple_server import make_server, WSGIRequestHandler +from wsgiref.simple_server import make_server, WSGIServer, WSGIRequestHandler from .openmetrics import exposition as openmetrics from .registry import REGISTRY @@ -64,15 +64,26 @@ def log_message(self, format, *args): """Log nothing.""" +class ThreadingWSGIServer(ThreadingMixIn, WSGIServer): + """Thread per request HTTP server.""" + # Make worker threads "fire and forget". Beginning with Python 3.7 this + # prevents a memory leak because ``ThreadingMixIn`` starts to gather all + # non-daemon threads in a list in order to join on them at server close. + daemon_threads = True + + def start_wsgi_server(port, addr='', registry=REGISTRY): """Starts a WSGI server for prometheus metrics as a daemon thread.""" app = make_wsgi_app(registry) - httpd = make_server(addr, port, app, handler_class=_SilentHandler) + httpd = make_server(addr, port, app, ThreadingWSGIServer, handler_class=_SilentHandler) t = threading.Thread(target=httpd.serve_forever) t.daemon = True t.start() +start_http_server = start_wsgi_server + + def generate_latest(registry=REGISTRY): """Returns the metrics from the registry in latest text format as a string.""" @@ -181,25 +192,6 @@ def factory(cls, registry): return MyMetricsHandler -class _ThreadingSimpleServer(ThreadingMixIn, HTTPServer): - """Thread per request HTTP server.""" - # Make worker threads "fire and forget". Beginning with Python 3.7 this - # prevents a memory leak because ``ThreadingMixIn`` starts to gather all - # non-daemon threads in a list in order to join on them at server close. - # Enabling daemon threads virtually makes ``_ThreadingSimpleServer`` the - # same as Python 3.7's ``ThreadingHTTPServer``. - daemon_threads = True - - -def start_http_server(port, addr='', registry=REGISTRY): - """Starts an HTTP server for prometheus metrics as a daemon thread""" - CustomMetricsHandler = MetricsHandler.factory(registry) - httpd = _ThreadingSimpleServer((addr, port), CustomMetricsHandler) - t = threading.Thread(target=httpd.serve_forever) - t.daemon = True - t.start() - - def write_to_textfile(path, registry): """Write metrics to the given path. From 050bb5c4f6430a77cb0a63fbf22b18c8a6ab043e Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Tue, 18 Feb 2020 18:45:25 +0100 Subject: [PATCH 07/10] Simplify ASGI documentation in README Signed-off-by: Emil Madsen --- README.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/README.md b/README.md index cf4eafa7..586f5315 100644 --- a/README.md +++ b/README.md @@ -311,8 +311,6 @@ start_wsgi_server(8000) To use Prometheus with [ASGI](http://asgi.readthedocs.org/en/latest/), there is `make_asgi_app` which creates an ASGI application. -Save the snippet below in a `myapp.py` file - ```python from prometheus_client import make_asgi_app @@ -321,17 +319,6 @@ app = make_asgi_app() Such an application can be useful when integrating Prometheus metrics with ASGI apps. -The app can be used to serve the metrics through an ASGI implementation, such -as [daphne](https://github.com/django/daphne) or -[uvicorn](https://www.uvicorn.org/). -```bash -# Install daphne if you do not have it -pip install daphne -daphne myapp:app -``` - -Visit http://localhost:8000/ to see the metrics - #### Flask To use Prometheus with [Flask](http://flask.pocoo.org/) we need to serve metrics through a Prometheus WSGI application. This can be achieved using [Flask's application dispatching](http://flask.pocoo.org/docs/latest/patterns/appdispatch/). Below is a working example. From d796bd56c33fbd59a90e240eb66c1664bf628429 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Tue, 18 Feb 2020 18:54:53 +0100 Subject: [PATCH 08/10] Utilize entire header instead of unwrapping half Signed-off-by: Emil Madsen --- prometheus_client/exposition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prometheus_client/exposition.py b/prometheus_client/exposition.py index 68387860..634066f7 100644 --- a/prometheus_client/exposition.py +++ b/prometheus_client/exposition.py @@ -169,7 +169,7 @@ def do_GET(self): status, header, output = _bake_output(registry, accept_header, params) # Return output self.send_response(int(status.split(' ')[0])) - self.send_header('Content-Type', header[1]) + self.send_header(*header) self.end_headers() self.wfile.write(output) From f62c7ce1c272d5a2c88297b8915dbd722be21850 Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Tue, 18 Feb 2020 18:58:47 +0100 Subject: [PATCH 09/10] Eliminate dependency on parameterized Signed-off-by: Emil Madsen --- tests/test_asgi.py | 21 +++++++++++++-------- tests/test_wsgi.py | 21 +++++++++++++-------- tox.ini | 2 -- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 2a30eaf9..b2d9a70f 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -2,7 +2,6 @@ import sys from unittest import TestCase -from parameterized import parameterized from prometheus_client import CollectorRegistry, Counter, generate_latest from prometheus_client.exposition import CONTENT_TYPE_LATEST @@ -81,13 +80,7 @@ def get_all_output(self): break return outputs - @parameterized.expand([ - ["counter", "A counter", 2], - ["counter", "Another counter", 3], - ["requests", "Number of requests", 5], - ["failed_requests", "Number of failed requests", 7], - ]) - def test_reports_metrics(self, metric_name, help_text, increments): + def validate_metrics(self, metric_name, help_text, increments): """ ASGI app serves the metrics from the provided registry. """ @@ -116,3 +109,15 @@ def test_reports_metrics(self, metric_name, help_text, increments): self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) self.assertIn("# TYPE " + metric_name + "_total counter\n", output) self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output) + + def test_report_metrics_1(self): + self.validate_metrics("counter", "A counter", 2) + + def test_report_metrics_2(self): + self.validate_metrics("counter", "Another counter", 3) + + def test_report_metrics_3(self): + self.validate_metrics("requests", "Number of requests", 5) + + def test_report_metrics_4(self): + self.validate_metrics("failed_requests", "Number of failed requests", 7) diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 73585ef9..af251ac4 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -2,7 +2,6 @@ import sys from unittest import TestCase -from parameterized import parameterized from wsgiref.util import setup_testing_defaults from prometheus_client import make_wsgi_app @@ -34,13 +33,7 @@ def assertIn(self, item, iterable): ) ) - @parameterized.expand([ - ["counter", "A counter", 2], - ["counter", "Another counter", 3], - ["requests", "Number of requests", 5], - ["failed_requests", "Number of failed requests", 7], - ]) - def test_reports_metrics(self, metric_name, help_text, increments): + def validate_metrics(self, metric_name, help_text, increments): """ WSGI app serves the metrics from the provided registry. """ @@ -62,3 +55,15 @@ def test_reports_metrics(self, metric_name, help_text, increments): self.assertIn("# HELP " + metric_name + "_total " + help_text + "\n", output) self.assertIn("# TYPE " + metric_name + "_total counter\n", output) self.assertIn(metric_name + "_total " + str(increments) + ".0\n", output) + + def test_report_metrics_1(self): + self.validate_metrics("counter", "A counter", 2) + + def test_report_metrics_2(self): + self.validate_metrics("counter", "Another counter", 3) + + def test_report_metrics_3(self): + self.validate_metrics("requests", "Number of requests", 5) + + def test_report_metrics_4(self): + self.validate_metrics("failed_requests", "Number of failed requests", 7) diff --git a/tox.ini b/tox.ini index 34329226..6cfef861 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,6 @@ envlist = coverage-clean,py26,py27,py34,py35,py36,py37,py38,pypy,pypy3,{py27,py3 deps = coverage pytest - parameterized [testenv:py26] ; Last pytest and py version supported on py26 . @@ -16,7 +15,6 @@ deps = pytest==2.9.2 coverage futures - parameterized [testenv:py27] deps = From 3551bc576c72357d41a2365d770a258a2b4bd9ee Mon Sep 17 00:00:00 2001 From: Emil Madsen Date: Wed, 19 Feb 2020 15:27:52 +0100 Subject: [PATCH 10/10] Only test asgi against latest python3 version Signed-off-by: Emil Madsen --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6cfef861..bcabd320 100644 --- a/tox.ini +++ b/tox.ini @@ -30,7 +30,7 @@ deps = deps = {[base]deps} {py27,py37,pypy,pypy3}: twisted - {py35,py36,py37,py38,pypy3}: asgiref + {py37,pypy3}: asgiref commands = coverage run --parallel -m pytest {posargs} ; Ensure test suite passes if no optional dependencies are present.