diff --git a/go/apps/jsbox/metrics.py b/go/apps/jsbox/metrics.py new file mode 100644 index 000000000..4b9c7a107 --- /dev/null +++ b/go/apps/jsbox/metrics.py @@ -0,0 +1,94 @@ +# -*- test-case-name: go.apps.jsbox.tests.test_metrics -*- +# -*- coding: utf-8 -*- + +"""Metrics for JS Box sandboxes""" + +import re + +from vumi.application.sandbox import SandboxResource +from vumi import log + +from vumi.blinkenlights.metrics import SUM, AVG, MIN, MAX + + +class MetricEventError(Exception): + """Raised when a command cannot be converted to a metric event.""" + + +class MetricEvent(object): + + AGGREGATORS = { + 'sum': SUM, + 'avg': AVG, + 'min': MIN, + 'max': MAX, + } + + NAME_REGEX = re.compile(r"^[a-zA-Z][a-zA-Z0-9]{,100}$") + + def __init__(self, store, metric, value, agg): + self.store = store + self.metric = metric + self.value = value + self.agg = agg + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return all((self.store == other.store, self.metric == other.metric, + self.value == other.value, self.agg is other.agg)) + + @classmethod + def _parse_name(cls, name, kind): + if name is None: + raise MetricEventError("Missing %s name." % (kind,)) + if not isinstance(name, basestring): + raise MetricEventError("Invalid type for %s name: %r" + % (kind, name)) + if not cls.NAME_REGEX.match(name): + raise MetricEventError("Invalid %s name: %r." % (kind, name)) + return name + + @classmethod + def _parse_value(cls, value): + try: + value = float(value) + except (ValueError, TypeError): + raise MetricEventError("Invalid metric value %r." % (value,)) + return value + + @classmethod + def _parse_agg(cls, agg): + if not isinstance(agg, basestring): + raise MetricEventError("Invalid metric aggregator %r" % (agg,)) + if agg not in cls.AGGREGATORS: + raise MetricEventError("Invalid metric aggregator %r." % (agg,)) + return cls.AGGREGATORS[agg] + + @classmethod + def from_command(cls, command): + store = cls._parse_name(command.get('store', 'default'), 'store') + metric = cls._parse_name(command.get('metric'), 'metric') + value = cls._parse_value(command.get('value')) + agg = cls._parse_agg(command.get('agg')) + return cls(store, metric, value, agg) + + +class MetricsResource(SandboxResource): + """Resource that provides metric storing.""" + + def _publish_event(self, api, ev): + conversation = self.app_worker.conversation_for_api(api) + self.app_worker.publish_account_metric(conversation.user_account.key, + ev.store, ev.metric, ev.value, + ev.agg) + + def handle_fire(self, api, command): + """Fire a metric value.""" + try: + ev = MetricEvent.from_command(command) + except MetricEventError, e: + log.warning(str(e)) + return self.reply(command, success=False, reason=unicode(e)) + self._publish_event(api, ev) + return self.reply(command, success=True) diff --git a/go/apps/jsbox/tests/test_metrics.py b/go/apps/jsbox/tests/test_metrics.py new file mode 100644 index 000000000..12d38bfc6 --- /dev/null +++ b/go/apps/jsbox/tests/test_metrics.py @@ -0,0 +1,139 @@ +"""Tests for go.apps.jsbox.metrics.""" + +import mock + +from twisted.trial.unittest import TestCase + +from vumi.application.sandbox import SandboxCommand +from vumi.tests.utils import LogCatcher + +from go.apps.jsbox.metrics import ( + MetricEvent, MetricEventError, MetricsResource) + + +class TestMetricEvent(TestCase): + + SUM = MetricEvent.AGGREGATORS['sum'] + + def test_create(self): + ev = MetricEvent('mystore', 'foo', 2.0, self.SUM) + self.assertEqual(ev.store, 'mystore') + self.assertEqual(ev.metric, 'foo') + self.assertEqual(ev.value, 2.0) + self.assertEqual(ev.agg, self.SUM) + + def test_eq(self): + ev1 = MetricEvent('mystore', 'foo', 1.5, self.SUM) + ev2 = MetricEvent('mystore', 'foo', 1.5, self.SUM) + self.assertEqual(ev1, ev2) + + def test_neq(self): + ev1 = MetricEvent('mystore', 'foo', 1.5, self.SUM) + ev2 = MetricEvent('mystore', 'bar', 1.5, self.SUM) + self.assertNotEqual(ev1, ev2) + + def test_from_command(self): + ev = MetricEvent.from_command({'store': 'mystore', 'metric': 'foo', + 'value': 1.5, 'agg': 'sum'}) + self.assertEqual(ev, MetricEvent('mystore', 'foo', 1.5, self.SUM)) + + def test_bad_store(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'foo bar', 'metric': 'foo', 'value': 1.5, + 'agg': 'sum'}) + + def test_bad_type_store(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': {}, 'metric': 'foo', 'value': 1.5, + 'agg': 'sum'}) + + def test_bad_metric(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': 'foo bar', 'value': 1.5, + 'agg': 'sum'}) + + def test_bad_type_metric(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': {}, 'value': 1.5, + 'agg': 'sum'}) + + def test_missing_metric(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'value': 1.5, 'agg': 'sum'}) + + def test_bad_value(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': 'foo', 'value': 'abc', + 'agg': 'sum'}) + + def test_bad_type_value(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': 'foo', 'value': {}, + 'agg': 'sum'}) + + def test_missing_value(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': 'foo', 'agg': 'sum'}) + + def test_bad_agg(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': 'foo', 'value': 1.5, + 'agg': 'foo'}) + + def test_bad_type_agg(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': 'foo', 'value': 1.5, + 'agg': {}}) + + def test_missing_agg(self): + self.assertRaises(MetricEventError, MetricEvent.from_command, { + 'store': 'mystore', 'metric': 'foo', 'value': 1.5}) + + +class TestMetricsResource(TestCase): + + SUM = MetricEvent.AGGREGATORS['sum'] + + def setUp(self): + self.conversation = mock.Mock() + self.app_worker = mock.Mock() + self.dummy_api = object() + self.resource = MetricsResource("test", self.app_worker, {}) + self.app_worker.conversation_for_api = mock.Mock( + return_value=self.conversation) + + def check_reply(self, reply, cmd, success): + self.assertEqual(reply['reply'], True) + self.assertEqual(reply['cmd_id'], cmd['cmd_id']) + self.assertEqual(reply['success'], success) + + def check_publish(self, store, metric, value, agg): + self.app_worker.publish_account_metric.assert_called_once_with( + self.conversation.user_account.key, store, metric, value, agg) + + def check_not_published(self): + self.assertFalse(self.app_worker.publish_account_metric.called) + + def test_handle_fire(self): + cmd = SandboxCommand(metric="foo", value=1.5, agg='sum') + reply = self.resource.handle_fire(self.dummy_api, cmd) + self.check_reply(reply, cmd, True) + self.check_publish('default', 'foo', 1.5, self.SUM) + + def _test_error(self, cmd, expected_error): + with LogCatcher() as lc: + reply = self.resource.handle_fire(self.dummy_api, cmd) + self.assertEqual(lc.messages(), [expected_error]) + self.check_reply(reply, cmd, False) + self.assertEqual(reply['reason'], expected_error) + self.check_not_published() + + def test_handle_fire_error(self): + cmd = SandboxCommand(metric="foo bar", value=1.5, agg='sum') + expected_error = "Invalid metric name: 'foo bar'." + self._test_error(cmd, expected_error) + + def test_non_ascii_metric_name_error(self): + cmd = SandboxCommand(metric=u"b\xe6r", value=1.5, agg='sum') + expected_error = "Invalid metric name: u'b\\xe6r'." + self._test_error(cmd, expected_error) diff --git a/go/vumitools/app_worker.py b/go/vumitools/app_worker.py index 07eddb91f..d96a914e7 100644 --- a/go/vumitools/app_worker.py +++ b/go/vumitools/app_worker.py @@ -230,6 +230,11 @@ def publish_conversation_metric(self, conversation, name, value, agg=None): conversation.user_account.key, conversation.key, name) self.publish_metric(name, value, agg) + def publish_account_metric(self, user_account_key, store, name, value, + agg=None): + name = "%s.%s.%s" % (user_account_key, store, name) + self.publish_metric(name, value, agg) + @inlineCallbacks def collect_message_metrics(self, conversation): """Collect message count metrics.