Skip to content
This repository has been archived by the owner on Jun 12, 2018. It is now read-only.

Implement a metrics resource using Vumi blinkenlights metrics publishers. #134

Merged
merged 8 commits into from
Dec 21, 2012
94 changes: 94 additions & 0 deletions go/apps/jsbox/metrics.py
Original file line number Diff line number Diff line change
@@ -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)
139 changes: 139 additions & 0 deletions go/apps/jsbox/tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -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])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't this in lc.errors ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we use log.warning to log the 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)
5 changes: 5 additions & 0 deletions go/vumitools/app_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down