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
89 changes: 89 additions & 0 deletions go/apps/jsbox/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# -*- 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 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 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,
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=str(e))
self._publish_event(api, ev)
return self.reply(command, success=True)
111 changes: 111 additions & 0 deletions go/apps/jsbox/tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""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_metric(self):
self.assertRaises(MetricEventError, MetricEvent.from_command, {
'store': 'mystore', 'metric': 'foo bar', '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_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_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, 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_handle_fire_error(self):
cmd = SandboxCommand(metric="foo bar", value=1.5, agg='sum')
expected_error = "Invalid metric name: 'foo bar'."
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()
10 changes: 10 additions & 0 deletions go/vumitools/app_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,16 @@ 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, store, name, value,
agg=None):
if isinstance(user_account, basestring):
user_account_key = user_account
else:
user_account_key = user_account.key
Copy link
Member

Choose a reason for hiding this comment

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

Do we really need to accept both of these here? I'd prefer to have the caller extract the key if necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't really mind -- I was just trying to fit in with publish_conversation_metric which takes a conversation but not require people to look up the account if they only have the key.

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