Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Audience Evaluation): Audience Logging #156

Merged
merged 19 commits into from
Feb 28, 2019

Conversation

oakbani
Copy link
Contributor

@oakbani oakbani commented Jan 2, 2019

Summary

This adds logging for audience evaluation.

Test plan

Unit tests written in

  • test_condition.py
  • test_audience.py

Issues

  • OASIS-3850

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.06%) to 99.635% when pulling 691c439 on oakbani/logging-for-audience into 2c35eb8 on master.

4 similar comments
@coveralls
Copy link

Coverage Status

Coverage decreased (-0.06%) to 99.635% when pulling 691c439 on oakbani/logging-for-audience into 2c35eb8 on master.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.06%) to 99.635% when pulling 691c439 on oakbani/logging-for-audience into 2c35eb8 on master.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.06%) to 99.635% when pulling 691c439 on oakbani/logging-for-audience into 2c35eb8 on master.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.06%) to 99.635% when pulling 691c439 on oakbani/logging-for-audience into 2c35eb8 on master.

@coveralls
Copy link

coveralls commented Jan 2, 2019

Coverage Status

Coverage increased (+0.02%) to 99.707% when pulling 3d0bfee on oakbani/logging-for-audience into a3b46a2 on master.

if audience_conditions is None or audience_conditions == []:
logger.info(logs.NO_AUDIENCE_ATTACHED.format(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added to log why it evaluates to True in this case. Not in doc

Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting idea to specifically mention the "no audiences" case. But to reduce the number of types of log messages, and to increase consistency with other SDKs, can we find a way to rely on the EVALUATING_AUDIENCES and AUDIENCE_EVALUATION_RESULT_COMBINED messages instead?

json.dumps(audience_conditions)
))

logger.debug(logs.USER_ATTRIBUTES.format(json.dumps(attributes)))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The doc sort of suggests that we log attributes with every audience evaluation. I have logged it here at top-level since attributes remain same irrespective of audience conditions

not validator.are_values_same_type(condition_value, user_value):
return None
if not validator.are_values_same_type(condition_value, user_value):
self.logger.debug(logs.MISMATCH_TYPE.format(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added this log to distinguish exactly what is inapplicable and what is type mismatch for exact evaluator.
Not in docs.
For exact, user value {}, [], () are inapplicable but integer 5 is a mismatch if condition value is a string

self.mock_client_logger = mock.MagicMock()

def test_evaluate__match_type__invalid(self):
log_level = 'warning'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added log_level variable in every testcase to make the expected logging level visible.

audience.conditionStructure,
lambda index: evaluate_custom_attr(audienceId, index)
)

result_log = str(result) if result is not None else 'UNKNOWN'
logger.debug(logs.AUDIENCE_EVALUATION_RESULT.format(audienceId, result_log))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

doc suggests that this should be info. I have made it debug to avoid logs being too verbose.
At info level we should be interested in the overall result, which we are already logging outside on line#87

@oakbani oakbani added the wip Work in progress label Jan 9, 2019
@oakbani oakbani removed the wip Work in progress label Jan 9, 2019
@oakbani oakbani requested a review from a team January 9, 2019 14:15
Copy link
Contributor

@nchilada nchilada left a comment

Choose a reason for hiding this comment

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

Thanks for doing this and sorry for the late review!

if audience_conditions is None or audience_conditions == []:
logger.info(logs.NO_AUDIENCE_ATTACHED.format(
Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting idea to specifically mention the "no audiences" case. But to reduce the number of types of log messages, and to increase consistency with other SDKs, can we find a way to rely on the EVALUATING_AUDIENCES and AUDIENCE_EVALUATION_RESULT_COMBINED messages instead?

class AudienceEvaluationLogs(object):
AUDIENCE_EVALUATION_RESULT = 'Audience "{}" evaluated to {}.'
AUDIENCE_EVALUATION_RESULT_COMBINED = 'Audiences for experiment "{}" collectively evaluated to {}.'
EVALUATING_AUDIENCES = 'Evaluating audiences for experiment "{}": "{}".'
Copy link
Contributor

Choose a reason for hiding this comment

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

To match the changes we made in optimizely/javascript-sdk#210

Suggested change
EVALUATING_AUDIENCES = 'Evaluating audiences for experiment "{}": "{}".'
EVALUATING_AUDIENCES_COMBINED = 'Evaluating audiences for experiment "{}": {}.'

AUDIENCE_EVALUATION_RESULT = 'Audience "{}" evaluated to {}.'
AUDIENCE_EVALUATION_RESULT_COMBINED = 'Audiences for experiment "{}" collectively evaluated to {}.'
EVALUATING_AUDIENCES = 'Evaluating audiences for experiment "{}": "{}".'
EVALUATING_AUDIENCE_WITH_CONDITIONS = 'Starting to evaluate audience "{}" with conditions: "{}".'
Copy link
Contributor

Choose a reason for hiding this comment

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

To match the changes we made in optimizely/javascript-sdk#210

Suggested change
EVALUATING_AUDIENCE_WITH_CONDITIONS = 'Starting to evaluate audience "{}" with conditions: "{}".'
EVALUATING_AUDIENCE = 'Evaluating audiences for experiment "{}": {}.'

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@@ -49,14 +63,28 @@ def evaluate_audience(audienceId):
if audience is None:
return None

return condition_tree_evaluator.evaluate(
logger.debug(logs.EVALUATING_AUDIENCE_WITH_CONDITIONS.format(audienceId, audience.conditions))
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
logger.debug(logs.EVALUATING_AUDIENCE_WITH_CONDITIONS.format(audienceId, audience.conditions))
logger.debug(logs.EVALUATING_AUDIENCE_WITH_CONDITIONS.format(audienceId, json.dumps(audience.conditions)))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, thanks!

audience.conditionStructure,
lambda index: evaluate_custom_attr(audienceId, index)
)

result_str = str(result) if result is not None else 'UNKNOWN'
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
result_str = str(result) if result is not None else 'UNKNOWN'
result_str = str(result).upper() if result is not None else 'UNKNOWN'

not validator.are_values_same_type(condition_value, user_value):
return None
self.logger.debug(logs.UNEXPECTED_TYPE.format(
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
self.logger.debug(logs.UNEXPECTED_TYPE.format(
self.logger.warning(logs.UNEXPECTED_TYPE.format(


if not validator.is_finite_number(condition_value) or not validator.is_finite_number(user_value):
if not validator.is_finite_number(user_value):
self.logger.debug(logs.UNEXPECTED_TYPE.format(
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
self.logger.debug(logs.UNEXPECTED_TYPE.format(
self.logger.warning(logs.UNEXPECTED_TYPE.format(


if not validator.is_finite_number(condition_value) or not validator.is_finite_number(user_value):
if not validator.is_finite_number(user_value):
self.logger.debug(logs.UNEXPECTED_TYPE.format(
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
self.logger.debug(logs.UNEXPECTED_TYPE.format(
self.logger.warning(logs.UNEXPECTED_TYPE.format(

return None

if not isinstance(user_value, string_types):
self.logger.debug(logs.UNEXPECTED_TYPE.format(
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
self.logger.debug(logs.UNEXPECTED_TYPE.format(
self.logger.warning(logs.UNEXPECTED_TYPE.format(

return None

if self.attributes.get(attribute_key) is None:
self.logger.warning(logs.NULL_ATTRIBUTE_VALUE.format(self._get_condition_log(index), attribute_key))
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
self.logger.warning(logs.NULL_ATTRIBUTE_VALUE.format(self._get_condition_log(index), attribute_key))
self.logger.debug(logs.NULL_ATTRIBUTE_VALUE.format(self._get_condition_log(index), attribute_key))

@oakbani
Copy link
Contributor Author

oakbani commented Feb 25, 2019

@nchilada Feedback addressed. The build is failing only for 3.4 due to some unrelated issue.

Copy link
Contributor

@nchilada nchilada left a comment

Choose a reason for hiding this comment

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

The code and tests are looking really good! @aliabbasrizvi this will be ready for final review and merging after the next round of updates.

return None
if not self.is_value_type_valid_for_exact_conditions(condition_value) or \
self.is_value_a_number(condition_value) and \
not validator.is_finite_number(condition_value):
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we use parens to explicitly group the latter two conditions?

(self.is_value_a_number(condition_value) and 
 not validator.is_finite_number(condition_value):

self.logger.warning(logs.UNKNOWN_CONDITION_VALUE.format(
self._get_condition_json(index)
))
return None
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: it would be more conventional to indent these two statements by only 2 spaces.

@@ -17,21 +17,20 @@
class AudienceEvaluationLogs(object):
AUDIENCE_EVALUATION_RESULT = 'Audience "{}" evaluated to {}.'
AUDIENCE_EVALUATION_RESULT_COMBINED = 'Audiences for experiment "{}" collectively evaluated to {}.'
EVALUATING_AUDIENCES = 'Evaluating audiences for experiment "{}": "{}".'
EVALUATING_AUDIENCE_WITH_CONDITIONS = 'Starting to evaluate audience "{}" with conditions: "{}".'
EVALUATING_AUDIENCE = 'Starting to evaluate audience "{}" with conditions: "{}".'
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we actually remove the surrounding quotation marks? That's what we ended up going with in other SDKs although we included quotes in the original design spec. Sorry!

Suggested change
EVALUATING_AUDIENCE = 'Starting to evaluate audience "{}" with conditions: "{}".'
EVALUATING_AUDIENCE = 'Starting to evaluate audience "{}" with conditions: {}.'

experiment.audienceIds = []
experiment.audienceConditions = []

with mock.patch('optimizely.logger.reset_logger', return_value=self.mock_client_logger):
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: does optimizely.logger.reset_logger actually need to return self.mock_client_logger given that we are passing self.mock_client_logger directly to the function we're testing, on the line below?

Same question elsewhere in the new tests.


self.mock_client_logger.assert_has_calls([
mock.call.debug('Evaluating audiences for experiment "test_experiment": ["11154", "11159"].'),
mock.call.debug('Starting to evaluate audience "11154" with conditions: "' + audience_11154.conditions + '".'),
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll need to change this to

Suggested change
mock.call.debug('Starting to evaluate audience "11154" with conditions: "' + audience_11154.conditions + '".'),
mock.call.debug('Starting to evaluate audience "11154" with conditions: ' + audience_11154.conditions + '.'),

per the suggestion that I've made in enums.py. Or even better, IMO:

Suggested change
mock.call.debug('Starting to evaluate audience "11154" with conditions: "' + audience_11154.conditions + '".'),
mock.call.debug('Starting to evaluate audience "11154" with conditions: ["and", ["or", ["or", '
'{"name": "test_attribute", "type": "custom_attribute", "value": "test_value"}]]].'),

(Same thing below)

mock_log.assert_called_once_with((
'Audience condition "{}" uses an unknown match '
'type. You may need to upgrade to a newer release of the Optimizely SDK.')
.format(json.dumps(expected_condition_log)))
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd still prefer to avoid using .format in our assertions since it's an ugly mirror, but it does help that we're only using it for the conditions now and not for other template variables in our log messages. I guess this is okay.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, for consistent unit tests we do need to use format in cases where representation varies across different python versions. Same for variable types.


self.assertStrictFalse(evaluator.evaluate(0))

self.mock_client_logger.debug.assert_not_called()
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we also confirm that info and warning weren't called?


self.assertStrictFalse(evaluator.evaluate(0))

self.mock_client_logger.debug.assert_not_called()
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we also confirm that info and warning weren't called?

mock_log = getattr(self.mock_client_logger, log_level)
mock_log.assert_called_once_with((
'Audience condition "{}" evaluated to UNKNOWN because a value of type "{}" was passed for '
'user attribute "favorite_constellation".').format(json.dumps(expected_condition_log), type({})))
Copy link
Contributor

Choose a reason for hiding this comment

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

It'd be really nice to inline the observed type into our assertion string. Both here and below. (As long as it works for both Python 2 and Python 3... might be tricky if we want to test the gt and lt match types with string-valued user attributes.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We get different outputs in python 2 and 3 so can't hardcode type in string.

AssertionError: Expected call: warning('Audience condition "{"name": "meters_travelled", "value": 48, "type": "custom_attribute", "match": "gt"}" evaluated to UNKNOWN because a value of type "<type 'str'>" was passed for user attribute "meters_travelled".')
Actual call: warning('Audience condition "{"name": "meters_travelled", "value": 48, "type": "custom_attribute", "match": "gt"}" evaluated to UNKNOWN because a value of type "<class 'str'>" was passed for user attribute "meters_travelled".')

@aliabbasrizvi
Copy link
Contributor

Thanks @nchilada . I am doing a review as well.

.travis.yml Outdated
@@ -25,7 +25,7 @@ jobs:
- stage: 'Linting'
language: python
python: "2.7"
install: "pip install flake8"
install: "pip install flake8==3.6.0"
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you put a note here why this is at 3.6.0. It will be useful for anyone else working on this file.

from . import condition as condition_helper
from . import condition_tree_evaluator
from .enums import AudienceEvaluationLogs as logs
Copy link
Contributor

Choose a reason for hiding this comment

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

nit. ... as audience_logs


logger.debug(logs.EVALUATING_AUDIENCES_COMBINED.format(
experiment.key,
json.dumps(audience_conditions)
Copy link
Contributor

Choose a reason for hiding this comment

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

The second argument here I feel is unnecessary as one can figure out what the audience conditions are from the datafile and IDs anyway will not help a lot to me as a customer. I guess we have resorted to this message in other SDKs as well @nchilada ?

Copy link
Contributor

Choose a reason for hiding this comment

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

@aliabbasrizvi yeah, same message in other SDKs... Part of my reasoning was that log messages might be recorded and used for debugging after the fact, by which time the datafile may have changed. Currently it's very difficult (and it may always be unintuitive) for customers to look up the datafile that was contemporaneous with a particular log message.


from six import string_types

from . import validator
from .enums import AudienceEvaluationLogs as logs
Copy link
Contributor

Choose a reason for hiding this comment

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

nit. ... as audience_logs

""" Method to validate if the value is valid for exact match type evaluation.

Args:
value: Value to validate.

Returns:
Boolean: True if value is a string type, or a boolean, or is finite. Otherwise False.
Boolean: True if value is a string type, or a boolean, or is a number. Otherwise False.
Copy link
Contributor

Choose a reason for hiding this comment

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

This sentence is too verbose. True if value is a string, boolean, or number.

Copy link
Contributor

@nchilada nchilada left a comment

Choose a reason for hiding this comment

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

Thanks for all the updates and explanations! Looks great. 🙂

@nchilada nchilada merged commit fafad4c into master Feb 28, 2019
@nchilada nchilada deleted the oakbani/logging-for-audience branch February 28, 2019 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants