diff --git a/Makefile b/Makefile index 2a63fb4..4226bfa 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,9 @@ TOX=$(shell "$(CMD_FROM_VENV)" "tox") PYTHON=$(shell "$(CMD_FROM_VENV)" "python") TOX_PY_LIST="$(shell $(TOX) -l | grep ^py | xargs | sed -e 's/ /,/g')" -.PHONY: clean docsclean pyclean test lint isort docs docker setup.py +.PHONY: clean docsclean pyclean test lint isort docs docker setup.py requirements -tox: clean venv +tox: clean requirements $(TOX) pyclean: @@ -29,10 +29,12 @@ docsclean: clean: pyclean docsclean @rm -rf venv + @rm -rf .tox venv: @virtualenv -p python2.7 venv @$(PIP) install -U "pip>=7.0" -q + @$(PIP) install -U "pip>=7.0" -q @$(PIP) install -r $(DEPS) test: venv pyclean @@ -52,16 +54,16 @@ docs: venv docsclean @$(TOX) -e docs docker: - $(DOCKER_COMPOSE) run --rm app bash + $(DOCKER_COMPOSE) run --rm --service-ports app bash docker/%: - $(DOCKER_COMPOSE) run --rm app make $* + $(DOCKER_COMPOSE) run --rm --service-ports app make $* setup.py: venv - $(PYTHON) setup_gen.py - $(PYTHON) setup.py check --restructuredtext + @$(PYTHON) setup_gen.py + @$(PYTHON) setup.py check --restructuredtext publish: setup.py - $(PYTHON) setup.py sdist upload + @$(PYTHON) setup.py sdist upload build: clean venv tox setup.py diff --git a/README.rst b/README.rst index 61734a1..20baf94 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,10 @@ Time Execution :target: http://py-timeexecution.readthedocs.org/en/latest/?badge=latest +This package is designed to record metrics of the application into a backend. +With the help of grafana_ you can easily create dashboards with them + + Features -------- @@ -25,6 +29,7 @@ Backends -------- - InfluxDB 0.8 +- Elasticsearch 2.1 Installation @@ -50,12 +55,14 @@ See the following example from time_execution import configure, time_execution from time_execution.backends.influxdb import InfluxBackend + from time_execution.backends.elasticsearch import ElasticsearchBackend # Setup the desired backend - influx = InfluxBackend(host='localhost', database='metrics', use_udp=False) + influx = InfluxBackend(host='influx', database='metrics', use_udp=False) + elasticsearch = ElasticsearchBackend('elasticsearch', index='metrics') # Configure the time_execution decorator - configure(backends=[influx]) + configure(backends=[influx, elasticsearch]) # Wrap the methods where u want the metrics @time_execution @@ -89,6 +96,28 @@ This will result in an entry in the influxdb } ] +And the following in Elasticsearch + +.. code-block:: json + + [ + { + "_index": "metrics-2016.01.28", + "_type": "metric", + "_id": "AVKIp9DpnPWamvqEzFB3", + "_score": null, + "_source": { + "timestamp": "2016-01-28T14:34:05.416968", + "hostname": "dfaa4928109f", + "name": "__main__.hello", + "value": 312 + }, + "sort": [ + 1453991645416 + ] + } + ] + Hooks ----- @@ -125,7 +154,7 @@ See the following example how to setup hooks. ) # Configure the time_execution decorator, but now with hooks - configure(backends=[influx], hooks=[my_hook]) + configure(backends=[backend], hooks=[my_hook]) Manually sending metrics ------------------------ @@ -141,3 +170,44 @@ See the following example. write_metric('cpu.load.1m', value=loadavg[0]) write_metric('cpu.load.5m', value=loadavg[1]) write_metric('cpu.load.15m', value=loadavg[2]) + +.. _grafana: http://grafana.org/ + + +Custom Backend +-------------- + +Writing a custom backend is very simple, all you need to do is create a class +with a `write` method. It is not required to extend `BaseMetricsBackend` +but in order to easily upgrade I recommend u do. + +.. code-block:: python + + from time_execution.backends.base import BaseMetricsBackend + + + class MetricsPrinter(BaseMetricsBackend): + def write(self, name, **data): + print(name, data) + + +Contribute +---------- + +You have something to contribute ? Great ! +A few things that may come in handy. + +Testing in this project is done via docker. There is a docker-compose to easily +get all the required containers up and running. + +There is a Makefile with a few targets that we use often: + +- `make test` +- `make isort` +- `make lint` +- `make build` +- `make setup.py` + +All of these make targets can be prefixed by `docker/`. This will execute +the target inside the docker container instead of on your local machine. +For example `make docker/build`. diff --git a/docker-compose.yml b/docker-compose.yml index 64ad328..113653b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,16 @@ influx: environment: - PRE_CREATE_DB=metrics - UDP_DB=metrics -# volumes: -# - data:/data +elasticsearch: + image: elasticsearch:2.1 + ports: + - "9200:9200" +kibana: + image: kibana + ports: + - "5601:5601" + links: + - elasticsearch grafana: image: grafana/grafana ports: @@ -17,11 +25,17 @@ grafana: - GF_SECURITY_ADMIN_PASSWORD=admin links: - influx + - elasticsearch influx_wait: image: kpndigital/tox links: - influx command: sh -c "while ! nc -w1 -z influx 8086; do sleep 1; done" +elasticsearch_wait: + image: kpndigital/tox + links: + - elasticsearch + command: sh -c "while ! nc -w1 -z elasticsearch 9200; do sleep 1; done" app: build: . volumes: @@ -30,3 +44,5 @@ app: links: - influx_wait - influx + - elasticsearch_wait + - elasticsearch diff --git a/docs/api/time_execution.backends.rst b/docs/api/time_execution.backends.rst index 30d75dd..95db635 100644 --- a/docs/api/time_execution.backends.rst +++ b/docs/api/time_execution.backends.rst @@ -14,6 +14,14 @@ time_execution.backends.base module :undoc-members: :show-inheritance: +time_execution.backends.elasticsearch module +-------------------------------------------- + +.. automodule:: time_execution.backends.elasticsearch + :members: + :undoc-members: + :show-inheritance: + time_execution.backends.influxdb module --------------------------------------- diff --git a/requirements/requirements-base.txt b/requirements/requirements-base.txt index 30775bb..515b4ce 100644 --- a/requirements/requirements-base.txt +++ b/requirements/requirements-base.txt @@ -1 +1,2 @@ -influxdb +influxdb>=2.11 +elasticsearch>=2.2.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cf9f91d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from time_execution import time_execution + + +@time_execution +def go(*args, **kwargs): + return True + + +@time_execution +def fqn_test(): + pass + + +@time_execution +class Dummy(object): + @time_execution + def go(self, *args, **kwargs): + pass diff --git a/tests/test_base_backend.py b/tests/test_base_backend.py new file mode 100644 index 0000000..7a86505 --- /dev/null +++ b/tests/test_base_backend.py @@ -0,0 +1,10 @@ +import unittest + +from time_execution.backends.base import BaseMetricsBackend + + +class TestBaseBackend(unittest.TestCase): + def test_write_method(self): + backend = BaseMetricsBackend() + with self.assertRaises(NotImplementedError): + backend.write('test') diff --git a/tests/test_elasticsearch.py b/tests/test_elasticsearch.py new file mode 100644 index 0000000..f41942e --- /dev/null +++ b/tests/test_elasticsearch.py @@ -0,0 +1,87 @@ +from tests.conftest import Dummy, go +from tests.test_base_backend import TestBaseBackend +from time_execution import configure +from time_execution.backends.elasticsearch import ElasticsearchBackend + + +class TestTimeExecution(TestBaseBackend): + def setUp(self): + super(TestTimeExecution, self).setUp() + + self.backend = ElasticsearchBackend( + 'elasticsearch', + index='unittest', + ) + + self._clear() + configure(backends=[self.backend]) + + def tearDown(self): + self._clear() + + def _clear(self): + self.backend.client.indices.delete(self.backend.index, ignore=404) + self.backend.client.indices.delete("{}*".format(self.backend.index), ignore=404) + + def _query_backend(self, name): + + self.backend.client.indices.refresh(self.backend.get_index()) + metrics = self.backend.client.search( + index=self.backend.get_index(), + body={ + "query": { + "term": {"name": name} + }, + } + ) + return metrics + + def test_time_execution(self): + + count = 4 + + for i in range(count): + go() + + metrics = self._query_backend(go.fqn) + self.assertEqual(metrics['hits']['total'], count) + + for metric in metrics['hits']['hits']: + self.assertTrue('value' in metric['_source']) + + def test_duration_field(self): + + configure(backends=[self.backend], duration_field='my_duration') + + go() + + for metric in self._query_backend(go.fqn)['hits']['hits']: + self.assertTrue('my_duration' in metric['_source']) + + def test_with_arguments(self): + go('hello', world='world') + Dummy().go('hello', world='world') + + metrics = self._query_backend(go.fqn) + self.assertEqual(metrics['hits']['total'], 1) + + metrics = self._query_backend(Dummy().go.fqn) + self.assertEqual(metrics['hits']['total'], 1) + + def test_hook(self): + + def test_args(**kwargs): + self.assertIn('response', kwargs) + self.assertIn('exception', kwargs) + self.assertIn('metric', kwargs) + return dict() + + def test_metadata(*args, **kwargs): + return dict(test_key='test value') + + configure(backends=[self.backend], hooks=[test_args, test_metadata]) + + go() + + for metric in self._query_backend(go.fqn)['hits']['hits']: + self.assertEqual(metric['_source']['test_key'], 'test value') diff --git a/tests/test_fqn.py b/tests/test_fqn.py new file mode 100644 index 0000000..4db12ba --- /dev/null +++ b/tests/test_fqn.py @@ -0,0 +1,11 @@ +import unittest + +from tests.conftest import Dummy, fqn_test + + +class TestFQN(unittest.TestCase): + + def test_fqn(self): + self.assertEqual(fqn_test.fqn, 'tests.conftest.fqn_test') + self.assertEqual(Dummy.fqn, 'tests.conftest.Dummy') + self.assertEqual(Dummy().go.fqn, 'tests.conftest.Dummy.go') diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..a24c83f --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,92 @@ +import unittest + +from tests.conftest import go +from time_execution import configure, time_execution +from time_execution.backends.base import BaseMetricsBackend + + +class AssertBackend(BaseMetricsBackend): + + def __init__(self, callback): + self.callback = callback + + def write(self, name, **data): + return self.callback(name, **data) + + +class TestTimeExecution(unittest.TestCase): + + def test_hook(self): + + def test_args(**kwargs): + self.assertIn('response', kwargs) + self.assertIn('exception', kwargs) + self.assertIn('metric', kwargs) + return dict() + + def test_metadata(*args, **kwargs): + return dict(test_key='test value') + + def asserts(name, **data): + self.assertEqual(data['test_key'], 'test value') + + configure( + backends=[AssertBackend(asserts)], + hooks=[test_args, test_metadata] + ) + + go() + + def test_hook_exception(self): + + message = 'exception message' + + def exception_hook(exception, **kwargs): + self.assertIsInstance(exception, TimeExecutionException) + return dict(exception_message=str(exception)) + + def asserts(name, **data): + self.assertEqual(data['exception_message'], message) + + configure( + backends=[AssertBackend(asserts)], + hooks=[exception_hook] + ) + + class TimeExecutionException(Exception): + pass + + @time_execution + def go(): + raise TimeExecutionException(message) + + with self.assertRaises(TimeExecutionException): + go() + + def test_hook_func_args(self): + param = 'foo' + + def hook(response, exception, metric, func_args, func_kwargs): + self.assertEqual(func_args[0], param) + + configure(hooks=[hook]) + + @time_execution + def go(param1): + return '200 OK' + + go(param) + + def test_hook_func_kwargs(self): + param = 'foo' + + def hook(response, exception, metric, func_args, func_kwargs): + self.assertEqual(func_kwargs['param1'], param) + + configure(hooks=[hook]) + + @time_execution + def go(param1): + return '200 OK' + + go(param1=param) diff --git a/tests/test_influxdb.py b/tests/test_influxdb.py new file mode 100644 index 0000000..b2d210b --- /dev/null +++ b/tests/test_influxdb.py @@ -0,0 +1,80 @@ +from influxdb.influxdb08.client import InfluxDBClientError +from tests.conftest import Dummy, go +from tests.test_base_backend import TestBaseBackend +from time_execution import configure +from time_execution.backends.influxdb import InfluxBackend + + +class TestTimeExecution(TestBaseBackend): + def setUp(self): + super(TestTimeExecution, self).setUp() + + self.database = 'unittest' + self.backend = InfluxBackend( + host='influx', + database=self.database, + use_udp=False + ) + + try: + self.backend.client.create_database(self.database) + except InfluxDBClientError: + # Something blew up so ignore it + pass + + configure(backends=[self.backend]) + + def tearDown(self): + self.backend.client.delete_database(self.database) + + def _query_backend(self, name): + query = 'select * from {}'.format(name) + metrics = self.backend.client.query(query)[0] + for metric in metrics['points']: + yield dict(zip(metrics['columns'], metric)) + + def test_time_execution(self): + count = 4 + for i in range(count): + go() + + metrics = list(self._query_backend(go.fqn)) + self.assertEqual(len(metrics), count) + + for metric in metrics: + self.assertTrue('value' in metric) + + def test_duration_field(self): + configure(backends=[self.backend], duration_field='my_duration') + go() + + for metric in self._query_backend(go.fqn): + self.assertTrue('my_duration' in metric) + + def test_with_arguments(self): + go('hello', world='world') + Dummy().go('hello', world='world') + + metrics = list(self._query_backend(go.fqn)) + self.assertEqual(len(metrics), 1) + + metrics = list(self._query_backend(Dummy().go.fqn)) + self.assertEqual(len(metrics), 1) + + def test_hook(self): + + def test_args(**kwargs): + self.assertIn('response', kwargs) + self.assertIn('exception', kwargs) + self.assertIn('metric', kwargs) + return dict() + + def test_metadata(*args, **kwargs): + return dict(test_key='test value') + + configure(backends=[self.backend], hooks=[test_args, test_metadata]) + + go() + + for metric in self._query_backend(go.fqn): + self.assertEqual(metric['test_key'], 'test value') diff --git a/tests/test_time_execution.py b/tests/test_time_execution.py deleted file mode 100644 index e5b05c1..0000000 --- a/tests/test_time_execution.py +++ /dev/null @@ -1,157 +0,0 @@ -import unittest - -from influxdb.influxdb08.client import InfluxDBClientError -from time_execution import configure, time_execution -from time_execution.backends.influxdb import InfluxBackend - - -@time_execution -def go(*args, **kwargs): - return True - - -@time_execution -def fqn_test(): - pass - - -@time_execution -class Dummy(object): - @time_execution - def go(self, *args, **kwargs): - pass - - -class TestTimeExecution(unittest.TestCase): - def setUp(self): - super(TestTimeExecution, self).setUp() - - self.database = 'unittest' - self.backend = InfluxBackend( - host='influx', - database=self.database, - use_udp=False - ) - - try: - self.backend.client.create_database(self.database) - except InfluxDBClientError: - # Something blew up so ignore it - pass - - configure(backends=[self.backend]) - - def tearDown(self): - self.backend.client.delete_database(self.database) - - def _query_influx(self, name): - query = 'select * from {}'.format(name) - metrics = self.backend.client.query(query)[0] - for metric in metrics['points']: - yield dict(zip(metrics['columns'], metric)) - - def test_fqn(self): - self.assertEqual(fqn_test.fqn, 'tests.test_time_execution.fqn_test') - self.assertEqual(Dummy.fqn, 'tests.test_time_execution.Dummy') - self.assertEqual(Dummy().go.fqn, 'tests.test_time_execution.Dummy.go') - - def test_time_execution(self): - - count = 4 - - for i in range(count): - go() - - metrics = list(self._query_influx(go.fqn)) - self.assertEqual(len(metrics), count) - - for metric in metrics: - self.assertTrue('value' in metric) - - def test_duration_field(self): - - configure(backends=[self.backend], duration_field='my_duration') - - go() - - for metric in self._query_influx(go.fqn): - self.assertTrue('my_duration' in metric) - - def test_with_arguments(self): - go('hello', world='world') - Dummy().go('hello', world='world') - - metrics = list(self._query_influx(go.fqn)) - self.assertEqual(len(metrics), 1) - - metrics = list(self._query_influx(Dummy().go.fqn)) - self.assertEqual(len(metrics), 1) - - def test_hook(self): - - def test_args(**kwargs): - self.assertIn('response', kwargs) - self.assertIn('exception', kwargs) - self.assertIn('metric', kwargs) - return dict() - - def test_metadata(*args, **kwargs): - return dict(test_key='test value') - - configure(backends=[self.backend], hooks=[test_args, test_metadata]) - - go() - - for metric in self._query_influx(go.fqn): - self.assertEqual(metric['test_key'], 'test value') - - def test_time_execution_with_exception(self): - - def exception_hook(exception, **kwargs): - self.assertTrue(exception) - return dict(exception_message=str(exception)) - - configure(backends=[self.backend], hooks=[exception_hook]) - - class TimeExecutionException(Exception): - message = 'default' - - @time_execution - def go(): - raise TimeExecutionException('test exception') - - with self.assertRaises(TimeExecutionException): - go() - - for metric in self._query_influx(go.fqn): - self.assertEqual(metric['exception_message'], 'test exception') - - def test_time_execution_with_func_args(self): - param = 'foo' - - def hook(response, exception, metric, func_args, func_kwargs): - self.assertEqual(func_args[0], param) - return metric - - configure(backends=[self.backend], hooks=[hook]) - - @time_execution - def go(param1): - return '200 OK' - - go(param) - - def test_time_execution_with_func_kwargs(self): - param = 'foo' - - def hook(response, exception, metric, func_args, func_kwargs): - self.assertEqual(func_kwargs['param1'], param) - return metric - - configure(backends=[self.backend], hooks=[hook]) - - @time_execution - def go(param1): - return '200 OK' - - go(param1=param) diff --git a/time_execution/backends/base.py b/time_execution/backends/base.py index 436a243..1035140 100644 --- a/time_execution/backends/base.py +++ b/time_execution/backends/base.py @@ -1,7 +1,8 @@ """ -Base metrics +Base metrics backend """ class BaseMetricsBackend(object): - pass + def write(self, name, **data): + raise NotImplementedError diff --git a/time_execution/backends/elasticsearch.py b/time_execution/backends/elasticsearch.py new file mode 100644 index 0000000..e16193b --- /dev/null +++ b/time_execution/backends/elasticsearch.py @@ -0,0 +1,98 @@ +from __future__ import absolute_import + +from datetime import datetime + +from elasticsearch import Elasticsearch +from time_execution.backends.base import BaseMetricsBackend + + +class ElasticsearchBackend(BaseMetricsBackend): + def __init__(self, hosts=None, index="metrics", doc_type="metric", + index_pattern="{index}-{date:%Y.%m.%d}", *args, **kwargs): + # Assign these in the backend as they are needed when writing metrics + # to elasticsearch + self.index = index + self.doc_type = doc_type + self.index_pattern = index_pattern + + # setup the client + self.client = Elasticsearch(hosts=hosts, *args, **kwargs) + + # ensure the index is created + self._setup_index() + self._setup_mapping() + + def get_index(self): + return self.index_pattern.format(index=self.index, date=datetime.now()) + + def _setup_index(self): + return self.client.indices.create(self.index, ignore=400) + + def _setup_mapping(self): + return self.client.indices.put_template( + name="timeexecution", + body={ + "template": "{}*".format(self.index), + "mappings": { + self.doc_type: { + "dynamic_templates": [ + { + "strings": { + "mapping": { + "index": "not_analyzed", + "omit_norms": True, + "type": "string" + }, + "match_mapping_type": "string" + } + } + ], + "_source": { + "enabled": True + }, + "properties": { + "name": { + "type": "string", + "index": "not_analyzed" + }, + "timestamp": { + "type": "date", + # "format": "dateOptionalTime", + "index": "not_analyzed" + }, + "hostname": { + "type": "string", + "index": "not_analyzed" + }, + "value": { + "type": "float", + "index": "not_analyzed" + }, + } + }, + }, + "settings": { + "number_of_shards": "1", + "number_of_replicas": "0", + }, + } + ) + + def write(self, name, **data): + """ + Write the metric to elasticsearch + + Args: + name (str): The name of the metric to write + data (dict): Additional data to store with the metric + """ + + data["name"] = name + data["timestamp"] = datetime.utcnow() + + self.client.index( + index=self.get_index(), + doc_type=self.doc_type, + id=None, + body=data + ) diff --git a/time_execution/backends/influxdb.py b/time_execution/backends/influxdb.py index 83549a1..4d1f57b 100644 --- a/time_execution/backends/influxdb.py +++ b/time_execution/backends/influxdb.py @@ -1,12 +1,8 @@ from __future__ import absolute_import -import socket - from influxdb.influxdb08 import InfluxDBClient from time_execution.backends.base import BaseMetricsBackend -SHORT_HOSTNAME = socket.gethostname() - class InfluxBackend(BaseMetricsBackend): def __init__(self, **kwargs): diff --git a/tox.ini b/tox.ini index 070a326..d5922c9 100644 --- a/tox.ini +++ b/tox.ini @@ -22,8 +22,8 @@ commands = python {toxinidir}/docs/apidoc.py -T -M -d 2 -o {toxinidir}/docs/api {toxinidir}/time_execution sphinx-build -W -b html {toxinidir}/docs {toxinidir}/docs/_build/html deps = + -rrequirements/requirements-base.txt -rrequirements/requirements-testing.txt - influxdb [testenv:isort-check] commands = isort -rc -c time_execution tests