Skip to content

Commit fc7bf6c

Browse files
oakbanialiabbasrizvi
authored andcommitted
Introduce Bot Filtering (#121)
1 parent 6ed11d7 commit fc7bf6c

File tree

8 files changed

+423
-36
lines changed

8 files changed

+423
-36
lines changed

optimizely/decision_service.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2017, Optimizely
1+
# Copyright 2017-2018, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -23,7 +23,6 @@
2323
Decision = namedtuple('Decision', 'experiment variation source')
2424
DECISION_SOURCE_EXPERIMENT = 'experiment'
2525
DECISION_SOURCE_ROLLOUT = 'rollout'
26-
RESERVED_BUCKETING_ID_ATTRIBUTE = '$opt_bucketing_id'
2726

2827

2928
class DecisionService(object):
@@ -48,7 +47,7 @@ def _get_bucketing_id(user_id, attributes):
4847
"""
4948

5049
attributes = attributes or {}
51-
return attributes.get(RESERVED_BUCKETING_ID_ATTRIBUTE, user_id)
50+
return attributes.get(enums.ControlAttributes.BUCKETING_ID, user_id)
5251

5352
def get_forced_variation(self, experiment, user_id):
5453
""" Determine if a user is forced into a variation for the given experiment and return that variation.

optimizely/event_builder.py

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from abc import abstractproperty
1818

1919
from . import version
20+
from .helpers import enums
2021
from .helpers import event_tag_utils
2122

2223

@@ -80,11 +81,20 @@ def _get_anonymize_ip(self):
8081
""" Get IP anonymization bool
8182
8283
Returns:
83-
bool 'anonymizeIP' value in the datafile.
84+
Boolean representing whether IP anonymization is enabled or not.
8485
"""
8586

8687
return self.config.get_anonymize_ip_value()
8788

89+
def _get_bot_filtering(self):
90+
""" Get bot filtering bool
91+
92+
Returns:
93+
Boolean representing whether bot filtering is enabled or not.
94+
"""
95+
96+
return self.config.get_bot_filtering_value()
97+
8898
@abstractmethod
8999
def _get_time(self):
90100
""" Get time in milliseconds to be added.
@@ -169,21 +179,29 @@ def _get_attributes(self, attributes):
169179

170180
params = []
171181

172-
if not attributes:
173-
return []
174-
175-
for attribute_key in attributes.keys():
176-
attribute_value = attributes.get(attribute_key)
177-
# Omit falsy attribute values
178-
if attribute_value:
179-
attribute = self.config.get_attribute(attribute_key)
180-
if attribute:
181-
params.append({
182-
self.EventParams.EVENT_ID: attribute.id,
183-
'key': attribute_key,
184-
'type': self.EventParams.CUSTOM,
185-
'value': attribute_value,
186-
})
182+
if isinstance(attributes, dict):
183+
for attribute_key in attributes.keys():
184+
attribute_value = attributes.get(attribute_key)
185+
# Omit falsy attribute values
186+
if attribute_value:
187+
attribute_id = self.config.get_attribute_id(attribute_key)
188+
if attribute_id:
189+
params.append({
190+
'entity_id': attribute_id,
191+
'key': attribute_key,
192+
'type': self.EventParams.CUSTOM,
193+
'value': attribute_value
194+
})
195+
196+
# Append Bot Filtering Attribute
197+
bot_filtering_value = self._get_bot_filtering()
198+
if isinstance(bot_filtering_value, bool):
199+
params.append({
200+
'entity_id': enums.ControlAttributes.BOT_FILTERING,
201+
'key': enums.ControlAttributes.BOT_FILTERING,
202+
'type': self.EventParams.CUSTOM,
203+
'value': bot_filtering_value
204+
})
187205

188206
return params
189207

optimizely/helpers/enums.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2016-2017, Optimizely
1+
# Copyright 2016-2018, Optimizely
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -59,3 +59,9 @@ class NotificationTypes(object):
5959
"""
6060
ACTIVATE = "ACTIVATE:experiment, user_id, attributes, variation, event"
6161
TRACK = "TRACK:event_key, user_id, attributes, event_tags, event"
62+
63+
64+
class ControlAttributes(object):
65+
BOT_FILTERING = '$opt_bot_filtering'
66+
BUCKETING_ID = '$opt_bucketing_id'
67+
USER_AGENT = '$opt_user_agent'

optimizely/project_config.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
SUPPORTED_VERSIONS = [V2_CONFIG_VERSION]
2626
UNSUPPORTED_VERSIONS = [V1_CONFIG_VERSION]
2727

28+
RESERVED_ATTRIBUTE_PREFIX = '$opt_'
29+
2830

2931
class ProjectConfig(object):
3032
""" Representation of the Optimizely project config. """
@@ -56,6 +58,7 @@ def __init__(self, datafile, logger, error_handler):
5658
self.feature_flags = config.get('featureFlags', [])
5759
self.rollouts = config.get('rollouts', [])
5860
self.anonymize_ip = config.get('anonymizeIP', False)
61+
self.bot_filtering = config.get('botFiltering', None)
5962

6063
# Utility maps for quick lookup
6164
self.group_id_map = self._generate_key_map(self.groups, 'id', entities.Group)
@@ -363,20 +366,28 @@ def get_event(self, event_key):
363366
self.error_handler.handle_error(exceptions.InvalidEventException(enums.Errors.INVALID_EVENT_KEY_ERROR))
364367
return None
365368

366-
def get_attribute(self, attribute_key):
367-
""" Get attribute for the provided attribute key.
369+
def get_attribute_id(self, attribute_key):
370+
""" Get attribute ID for the provided attribute key.
368371
369372
Args:
370373
attribute_key: Attribute key for which attribute is to be fetched.
371374
372375
Returns:
373-
Attribute corresponding to the provided attribute key.
376+
Attribute ID corresponding to the provided attribute key.
374377
"""
375378

376379
attribute = self.attribute_key_map.get(attribute_key)
380+
has_reserved_prefix = attribute_key.startswith(RESERVED_ATTRIBUTE_PREFIX)
377381

378382
if attribute:
379-
return attribute
383+
if has_reserved_prefix:
384+
self.logger.warning(('Attribute %s unexpectedly has reserved prefix %s; using attribute ID '
385+
'instead of reserved attribute name.' % (attribute_key, RESERVED_ATTRIBUTE_PREFIX)))
386+
387+
return attribute.id
388+
389+
if has_reserved_prefix:
390+
return attribute_key
380391

381392
self.logger.error('Attribute "%s" is not in datafile.' % attribute_key)
382393
self.error_handler.handle_error(exceptions.InvalidAttributeException(enums.Errors.INVALID_ATTRIBUTE_ERROR))
@@ -595,3 +606,12 @@ def get_anonymize_ip_value(self):
595606
"""
596607

597608
return self.anonymize_ip
609+
610+
def get_bot_filtering_value(self):
611+
""" Gets the bot filtering value.
612+
613+
Returns:
614+
A boolean value that indicates if bot filtering should be enabled.
615+
"""
616+
617+
return self.bot_filtering

tests/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def setUp(self):
145145
'accountId': '12001',
146146
'projectId': '111111',
147147
'version': '4',
148+
'botFiltering': True,
148149
'events': [{
149150
'key': 'test_event',
150151
'experimentIds': ['111127'],

tests/test_config.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def test_init__with_v4_datafile(self):
172172
'revision': '42',
173173
'version': '4',
174174
'anonymizeIP': False,
175+
'botFiltering': True,
175176
'events': [{
176177
'key': 'test_event',
177178
'experimentIds': ['111127'],
@@ -387,6 +388,7 @@ def test_init__with_v4_datafile(self):
387388
self.assertEqual(config_dict['revision'], project_config.revision)
388389
self.assertEqual(config_dict['experiments'], project_config.experiments)
389390
self.assertEqual(config_dict['events'], project_config.events)
391+
self.assertEqual(config_dict['botFiltering'], project_config.bot_filtering)
390392

391393
expected_group_id_map = {
392394
'19228': entities.Group(
@@ -679,6 +681,21 @@ def test_get_project_id(self):
679681

680682
self.assertEqual(self.config_dict['projectId'], self.project_config.get_project_id())
681683

684+
def test_get_bot_filtering(self):
685+
""" Test that bot filtering is retrieved correctly when using get_bot_filtering_value. """
686+
687+
# Assert bot filtering is None when not provided in data file
688+
self.assertTrue('botFiltering' not in self.config_dict)
689+
self.assertIsNone(self.project_config.get_bot_filtering_value())
690+
691+
# Assert bot filtering is retrieved as provided in the data file
692+
opt_obj = optimizely.Optimizely(json.dumps(self.config_dict_with_features))
693+
project_config = opt_obj.config
694+
self.assertEqual(
695+
self.config_dict_with_features['botFiltering'],
696+
project_config.get_bot_filtering_value()
697+
)
698+
682699
def test_get_experiment_from_key__valid_key(self):
683700
""" Test that experiment is retrieved correctly for valid experiment key. """
684701

@@ -787,16 +804,27 @@ def test_get_event__invalid_key(self):
787804

788805
self.assertIsNone(self.project_config.get_event('invalid_key'))
789806

790-
def test_get_attribute__valid_key(self):
791-
""" Test that attribute is retrieved correctly for valid attribute key. """
807+
def test_get_attribute_id__valid_key(self):
808+
""" Test that attribute ID is retrieved correctly for valid attribute key. """
792809

793-
self.assertEqual(entities.Attribute('111094', 'test_attribute'),
794-
self.project_config.get_attribute('test_attribute'))
810+
self.assertEqual('111094',
811+
self.project_config.get_attribute_id('test_attribute'))
795812

796-
def test_get_attribute__invalid_key(self):
813+
def test_get_attribute_id__invalid_key(self):
797814
""" Test that None is returned when provided attribute key is invalid. """
798815

799-
self.assertIsNone(self.project_config.get_attribute('invalid_key'))
816+
self.assertIsNone(self.project_config.get_attribute_id('invalid_key'))
817+
818+
def test_get_attribute_id__reserved_key(self):
819+
""" Test that Attribute Key is returned as ID when provided attribute key is reserved key. """
820+
self.assertEqual('$opt_user_agent',
821+
self.project_config.get_attribute_id('$opt_user_agent'))
822+
823+
def test_get_attribute_id__unknown_key_with_opt_prefix(self):
824+
""" Test that Attribute Key is returned as ID when provided attribute key is not
825+
present in the datafile but has $opt prefix. """
826+
self.assertEqual('$opt_interesting',
827+
self.project_config.get_attribute_id('$opt_interesting'))
800828

801829
def test_get_group__valid_id(self):
802830
""" Test that group is retrieved correctly for valid group ID. """
@@ -1074,6 +1102,7 @@ def test_set_forced_variation_when_called_to_remove_forced_variation(self):
10741102

10751103

10761104
class ConfigLoggingTest(base.BaseTest):
1105+
10771106
def setUp(self):
10781107
base.BaseTest.setUp(self)
10791108
self.optimizely = optimizely.Optimizely(json.dumps(self.config_dict),
@@ -1136,14 +1165,25 @@ def test_get_event__invalid_key(self):
11361165

11371166
mock_config_logging.error.assert_called_once_with('Event "invalid_key" is not in datafile.')
11381167

1139-
def test_get_attribute__invalid_key(self):
1168+
def test_get_attribute_id__invalid_key(self):
11401169
""" Test that message is logged when provided attribute key is invalid. """
11411170

11421171
with mock.patch.object(self.project_config, 'logger') as mock_config_logging:
1143-
self.project_config.get_attribute('invalid_key')
1172+
self.project_config.get_attribute_id('invalid_key')
11441173

11451174
mock_config_logging.error.assert_called_once_with('Attribute "invalid_key" is not in datafile.')
11461175

1176+
def test_get_attribute_id__key_with_opt_prefix_but_not_a_control_attribute(self):
1177+
""" Test that message is logged when provided attribute key has $opt_ in prefix and
1178+
key is not one of the control attributes. """
1179+
self.project_config.attribute_key_map['$opt_abc'] = entities.Attribute('007', '$opt_abc')
1180+
1181+
with mock.patch.object(self.project_config, 'logger') as mock_config_logging:
1182+
self.project_config.get_attribute_id('$opt_abc')
1183+
1184+
mock_config_logging.warning.assert_called_once_with(("Attribute $opt_abc unexpectedly has reserved prefix $opt_; "
1185+
"using attribute ID instead of reserved attribute name."))
1186+
11471187
def test_get_group__invalid_id(self):
11481188
""" Test that message is logged when provided group ID is invalid. """
11491189

@@ -1210,12 +1250,12 @@ def test_get_event__invalid_key(self):
12101250
enums.Errors.INVALID_EVENT_KEY_ERROR,
12111251
self.project_config.get_event, 'invalid_key')
12121252

1213-
def test_get_attribute__invalid_key(self):
1253+
def test_get_attribute_id__invalid_key(self):
12141254
""" Test that exception is raised when provided attribute key is invalid. """
12151255

12161256
self.assertRaisesRegexp(exceptions.InvalidAttributeException,
12171257
enums.Errors.INVALID_ATTRIBUTE_ERROR,
1218-
self.project_config.get_attribute, 'invalid_key')
1258+
self.project_config.get_attribute_id, 'invalid_key')
12191259

12201260
def test_get_group__invalid_id(self):
12211261
""" Test that exception is raised when provided group ID is invalid. """

0 commit comments

Comments
 (0)