Commit 64a93514 by Gabe Mulley

Merge pull request #26 from mulby/gabe/processors

support event processors
parents 35691e74 420dba33
......@@ -11,7 +11,8 @@ from eventtracking.tracker import Tracker
from eventtracking.locator import ThreadLocalContextLocator
DJANGO_SETTING_NAME = 'TRACKING_BACKENDS'
DJANGO_BACKEND_SETTING_NAME = 'TRACKING_BACKENDS'
DJANGO_PROCESSOR_SETTING_NAME = 'TRACKING_PROCESSORS'
class DjangoTracker(Tracker):
......@@ -22,13 +23,13 @@ class DjangoTracker(Tracker):
def __init__(self):
backends = self.create_backends_from_settings()
super(DjangoTracker, self).__init__(backends, ThreadLocalContextLocator())
processors = self.create_processors_from_settings()
super(DjangoTracker, self).__init__(backends, ThreadLocalContextLocator(), processors)
def create_backends_from_settings(self):
"""
Expects the Django setting `setting_name` (defaults to
"TRACKING_BACKENDS") to be defined and point to a dictionary of
backend engine configurations.
Expects the Django setting "TRACKING_BACKENDS" to be defined and point
to a dictionary of backend engine configurations.
Example::
......@@ -47,46 +48,71 @@ class DjangoTracker(Tracker):
},
}
"""
config = getattr(settings, DJANGO_SETTING_NAME, {})
config = getattr(settings, DJANGO_BACKEND_SETTING_NAME, {})
backends = {}
for name, values in config.iteritems():
# Ignore empty values to turn-off default tracker backends
if values and 'ENGINE' in values:
engine = values['ENGINE']
options = values.get('OPTIONS', {})
backend = self.instantiate_backend_from_name(engine, options)
backend = self.instantiate_from_dict(values)
backends[name] = backend
return backends
def instantiate_backend_from_name(self, name, options):
def instantiate_from_dict(self, values):
"""
Instantiate an event tracker backend from the full module path to
the backend class. Useful when setting backends from configuration
files.
Constructs an object given a dictionary containing an "ENGINE" key
which contains the full module path to the class, and an "OPTIONS"
key which contains a dictionary that will be passed in to the
constructor as keyword args.
"""
# Parse backend name
name = values['ENGINE']
options = values.get('OPTIONS', {})
# Parse the name
parts = name.split('.')
module_name = '.'.join(parts[:-1])
class_name = parts[-1]
# Get and verify the backend class
# Get the class
try:
module = import_module(module_name)
cls = getattr(module, class_name)
except (ValueError, AttributeError, TypeError, ImportError):
raise ValueError('Cannot find event tracker backend %s' % name)
raise ValueError('Cannot find class %s' % name)
backend = cls(**options)
if not hasattr(backend, 'send') or not callable(backend.send):
raise ValueError('Backend %s does not have a callable "send" method.' % name)
return cls(**options)
def create_processors_from_settings(self):
"""
Expects the Django setting "TRACKING_PROCESSORS" to be defined and
point to a list of backend engine configurations.
Example::
TRACKING_PROCESSORS = [
{
'ENGINE': 'some.arbitrary.Processor'
},
{
'ENGINE': 'some.arbitrary.OtherProcessor',
'OPTIONS': {
'user': 'foo'
}
},
]
"""
config = getattr(settings, DJANGO_PROCESSOR_SETTING_NAME, [])
processors = []
for values in config:
# Ignore empty values to turn-off default tracker backends
if values and 'ENGINE' in values:
processors.append(self.instantiate_from_dict(values))
return backend
return processors
def override_default_tracker():
......
......@@ -137,6 +137,23 @@ class TestConfiguration(TestCase):
django.override_default_tracker()
self.assertTrue(isinstance(tracker.get_tracker(), tracker.Tracker))
@override_settings(TRACKING_PROCESSORS=[
{
'ENGINE': 'eventtracking.django.tests.test_configuration.NopProcessor'
}
])
def test_single_processor(self):
self.configure_tracker()
self.assertEquals(len(self.tracker.processors), 1)
self.assertTrue(isinstance(self.tracker.processors[0], NopProcessor))
@override_settings(TRACKING_PROCESSORS=[
{}
])
def test_missing_processor_engine(self):
self.configure_tracker()
self.assertEquals(len(self.tracker.processors), 0)
class TrivialFakeBackend(object):
"""A trivial fake backend without any options"""
......@@ -157,3 +174,10 @@ class FakeBackendWithOptions(TrivialFakeBackend):
def __init__(self, **kwargs):
super(FakeBackendWithOptions, self).__init__()
self.option = kwargs.get('option', None)
class NopProcessor(object):
"""Changes every event"""
def __call__(self, event):
pass
......@@ -184,3 +184,75 @@ class TestTrack(TestCase): # pylint: disable=missing-docstring
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.name)
def test_single_processor(self):
self.tracker.processors.append(self.change_name)
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.changed_name)
def change_name(self, event):
"""Modify the event type of the event"""
event['name'] = sentinel.changed_name
return event
def test_non_callable_processor(self):
self.tracker.processors.append(object())
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.name)
def test_callable_class_processor(self):
class SampleProcessor(object):
"""An event processing class"""
def __call__(self, event):
"""Modify the event type"""
event['name'] = sentinel.class_name
self.tracker.processors.append(SampleProcessor())
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.class_name)
def test_processor_chain(self):
def ensure_modified_event(event):
"""Assert the first processor added a field to the event"""
self.assertIn(sentinel.key, event)
self.assertEquals(event[sentinel.key], sentinel.value)
return event
self.tracker.processors.extend([self.change_name, ensure_modified_event])
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.changed_name)
def test_processor_failure(self):
def always_fail(event): # pylint: disable=unused-argument
"""Always raises an error"""
raise ValueError
self.tracker.processors.extend([always_fail, self.change_name])
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.changed_name)
def test_processor_returns_none(self):
def return_none(event): # pylint: disable=unused-argument
"""Don't return the event"""
pass
self.tracker.processors.append(return_none)
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.name)
def test_processor_modifies_the_same_event_object(self):
def forget_return(event):
"""Modify the event without returning it"""
event['name'] = sentinel.forgotten_return
def ensure_name_changed(event):
"""Assert the event type has been modified even though the event wasn't returned"""
self.assertEquals(event['name'], sentinel.forgotten_return)
self.tracker.processors.extend([forget_return, ensure_name_changed])
self.tracker.emit(sentinel.name)
self.assert_backend_called_with(sentinel.forgotten_return)
......@@ -36,9 +36,14 @@ class Tracker(object):
Track application events. Holds references to a set of backends that will
be used to persist any events that are emitted.
"""
def __init__(self, backends=None, context_locator=None):
def __init__(self, backends=None, context_locator=None, processors=None):
self.backends = backends or {}
self.context_locator = context_locator or DefaultContextLocator()
self.processors = processors or []
for backend in backends.itervalues():
if not hasattr(backend, 'send') or not callable(backend.send):
raise ValueError('Backend %s does not have a callable "send" method.' % backend.__class__.__name__)
@property
def located_context(self):
......@@ -61,16 +66,44 @@ class Tracker(object):
Note that all values provided must be serializable.
"""
full_event = {
event = {
'name': name or UNKNOWN_EVENT_TYPE,
'timestamp': datetime.now(UTC),
'data': data or {},
'context': self.resolve_context()
}
event = self.process_event(event)
self.send_to_backends(event)
def process_event(self, event):
"""
Executes all event processors on the event in order.
`event` is a nested dictionary that represents the event.
Returns the modified event.
"""
for processor in self.processors:
try:
modified_event = processor(event)
if modified_event is not None:
event = modified_event
except Exception: # pylint: disable=broad-except
LOG.exception(
'Failed to execute processor: {0}'.format(processor)
)
return event
def send_to_backends(self, event):
"""Sends the event to all registered backends."""
for name, backend in self.backends.iteritems():
try:
backend.send(full_event)
backend.send(event)
except Exception: # pylint: disable=broad-except
LOG.exception(
'Unable to send event to backend: {0}'.format(name)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment