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(ep-factory): Implemented Event Factory and User Event Factory #194

Merged
merged 33 commits into from
Sep 20, 2019
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
232f0a0
feat: Add event_batch datamodels.
Jul 19, 2019
2320939
fix: unit test fixes.
Jul 22, 2019
fbb5a68
Merge branch 'master' into epmodels
mnoman09 Jul 22, 2019
c162465
feat: add event_factory and user_event_factory for event processing.
Jul 24, 2019
633b095
Removed third party dependency
msohailhussain Jul 26, 2019
8a5f531
Merge branch 'master' into sohail/pr-189
mnoman09 Aug 1, 2019
35fcd82
Merge remote-tracking branch 'origin/sohail/pr-189' into sohail/pr-193
mnoman09 Aug 1, 2019
d7201cb
Removed HasAttr
mnoman09 Aug 1, 2019
c829fca
Merge branch 'master' into sohail/pr-189
mnoman09 Aug 16, 2019
c3c9d46
Addressed feedback. Restructured classes.
msohailhussain Aug 20, 2019
a2eeb85
Merge branch 'sohail/pr-189' into sohail/pr-193
mnoman09 Aug 20, 2019
a045291
update: structural changes.
Aug 20, 2019
23ab6ce
fix: addressed more feedback
Aug 21, 2019
6474e98
update: removed print statements.
mnoman09 Aug 21, 2019
1184568
merged PR 189
mnoman09 Aug 21, 2019
592a306
fix: use VisitorAttribute class to create visitor attributes list.
Aug 21, 2019
c4a412a
fix: fix log_event payload.
Aug 22, 2019
2a3374c
removed commented code
mnoman09 Aug 22, 2019
ce44c11
fix linter error.
mnoman09 Aug 22, 2019
d4f2c66
Merge branch 'master' into sohail/pr-189
rashidsp Aug 28, 2019
80b0963
fix: addressed minor feedback.
Aug 28, 2019
071460a
fix: addressed minor feedback.
Aug 28, 2019
900d96d
Merge branch 'sohail/pr-189' of github.com:optimizely/python-sdk into…
Aug 28, 2019
8cd8547
Merge branch 'sohail/pr-189' into sohail/pr-193
Aug 28, 2019
e628e0f
fix: addressed review comments.
mariamjamal94 Sep 2, 2019
f851754
fix: addresses review comments.
mariamjamal94 Sep 4, 2019
65a752b
fix: addresses review comment.
mariamjamal94 Sep 5, 2019
8998580
fix: updated docstring for LogEvent.
mariamjamal94 Sep 6, 2019
c3d5b07
Merge branch 'master' into sohail/pr-193
mariamjamal94 Sep 12, 2019
b5d461b
Merge branch 'master' into sohail/pr-193
aliabbasrizvi Sep 16, 2019
5ef24de
fix: address import order issues.
mariamjamal94 Sep 18, 2019
1d5aeca
Merge branch 'master' into sohail/pr-193
oakbani Sep 20, 2019
f97d430
nit addressed
msohailhussain Sep 20, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 12 additions & 0 deletions optimizely/event/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2019, Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
181 changes: 181 additions & 0 deletions optimizely/event/event_factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Copyright 2019 Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json

from .user_event import ConversionEvent, ImpressionEvent
from .payload import Decision, EventBatch, Snapshot, SnapshotEvent, Visitor, VisitorAttribute
from .log_event import LogEvent
Copy link
Contributor

Choose a reason for hiding this comment

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

Should have mentioned earlier, but import order here is incorrect

It should be external packages (alphabetic), internal packages (alphabetic), files from same package (alphabetic)

from optimizely.helpers import enums, event_tag_utils, validator

CUSTOM_ATTRIBUTE_FEATURE_TYPE = 'custom'


class EventFactory(object):
""" EventFactory builds LogEvent object from a given UserEvent.
This class serves to separate concerns between events in the SDK and the API used
to record the events via the Optimizely Events API ("https://developers.optimizely.com/x/events/api/index.html")
"""

EVENT_ENDPOINT = 'https://logx.optimizely.com/v1/events'
HTTP_VERB = 'POST'
HTTP_HEADERS = {'Content-Type': 'application/json'}
ACTIVATE_EVENT_KEY = 'campaign_activated'

@classmethod
def create_log_event(cls, user_events, logger):
""" Create LogEvent instance.

Args:
user_events: An array of UserEvent instances.
logger: Provides a logger instance.

Returns:
LogEvent instance.
"""

def _dict_clean(obj):
Copy link

Choose a reason for hiding this comment

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

I think _dict_clean should be a static method, not declared inside create_log_event.

""" Helper method to remove keys from dictionary with None values. """

result = {}
for k, v in obj:
if v is None and k in ['revenue', 'value', 'tags', 'decisions']:
continue
else:
result[k] = v
return result

if not isinstance(user_events, list):
mjc1283 marked this conversation as resolved.
Show resolved Hide resolved
user_events = [user_events]

visitors = []

for user_event in user_events:
visitors.append(cls._create_visitor(user_event, logger))
Copy link

Choose a reason for hiding this comment

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

cls._create_visitor could return None. We have to filter out None values from the return value before appending it to visitors.

user_context = user_event.event_context

event_batch = EventBatch(
user_context.account_id,
user_context.project_id,
user_context.revision,
user_context.client_name,
user_context.client_version,
user_context.anonymize_ip,
True
)

if len([x for x in visitors if x is not None]) == 0:
return None

event_batch.visitors = visitors

event_params = json.loads(
json.dumps(event_batch.__dict__, default=lambda o: o.__dict__),
object_pairs_hook=_dict_clean
)

return LogEvent(cls.EVENT_ENDPOINT, event_params, cls.HTTP_VERB, cls.HTTP_HEADERS)

@classmethod
def _create_visitor(cls, user_event, logger):
mjc1283 marked this conversation as resolved.
Show resolved Hide resolved
if not user_event:
Copy link

Choose a reason for hiding this comment

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

Why is this check necssary? In what circumstances is user_event falsy?

return None

if isinstance(user_event, ImpressionEvent):
decision = Decision(
user_event.experiment.layerId if user_event.experiment else None,
Copy link

Choose a reason for hiding this comment

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

If user_event.experiment is None or user_event.variation is None, that seems like an error or invalid data, right? Shouldn't all ImpressionEvents have experiment and variation?

user_event.experiment.id if user_event.experiment else None,
user_event.variation.id if user_event.variation else None
)

snapshot_event = SnapshotEvent(
user_event.experiment.layerId if user_event.experiment else None,
user_event.uuid,
cls.ACTIVATE_EVENT_KEY,
user_event.timestamp
)

snapshot = Snapshot([snapshot_event], [decision])

visitor = Visitor([snapshot], user_event.visitor_attributes, user_event.user_id)

return visitor

elif isinstance(user_event, ConversionEvent):
revenue = event_tag_utils.get_revenue_value(user_event.event_tags)
value = event_tag_utils.get_numeric_value(user_event.event_tags, logger)

snapshot_event = SnapshotEvent(
user_event.event.id if user_event.event else None,
user_event.uuid,
user_event.event.key if user_event.event else None,
user_event.timestamp,
revenue,
value,
user_event.event_tags
)

snapshot = Snapshot([snapshot_event])

visitor = Visitor([snapshot], user_event.visitor_attributes, user_event.user_id)

return visitor

else:
# include log message for invalid event type
Copy link

Choose a reason for hiding this comment

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

We have to log an error message here.

return

@staticmethod
def build_attribute_list(attributes, project_config):
""" Create Vistor Attribute List.

Args:
attributes: Dict representing user attributes and values which need to be recorded.
Copy link

Choose a reason for hiding this comment

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

If attributes might not be a dict, let's mention what else it could be.

project_config: Instance of ProjectConfig.

Returns:
List consisting of valid attributes for the user. Empty otherwise.
"""

if project_config is None:
return None
Copy link

Choose a reason for hiding this comment

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

Let's return an empty list in this case, instead of None.


attributes_list = []

if isinstance(attributes, dict):
mjc1283 marked this conversation as resolved.
Show resolved Hide resolved
for attribute_key in attributes.keys():
attribute_value = attributes.get(attribute_key)
# Omit attribute values that are not supported by the log endpoint.
if validator.is_attribute_valid(attribute_key, attribute_value):
attribute_id = project_config.get_attribute_id(attribute_key)
if attribute_id:
attributes_list.append(
VisitorAttribute(
attribute_id,
attribute_key,
CUSTOM_ATTRIBUTE_FEATURE_TYPE,
attribute_value)
)

# Append Bot Filtering Attribute
bot_filtering_value = project_config.get_bot_filtering_value()
if isinstance(bot_filtering_value, bool):
attributes_list.append(
VisitorAttribute(
enums.ControlAttributes.BOT_FILTERING,
enums.ControlAttributes.BOT_FILTERING,
CUSTOM_ATTRIBUTE_FEATURE_TYPE,
bot_filtering_value)
)

return attributes_list
22 changes: 22 additions & 0 deletions optimizely/event/log_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright 2019 Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


class LogEvent(object):
""" Representation of an event which can be sent to the Optimizely logging endpoint. """
Copy link
Contributor

Choose a reason for hiding this comment

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

nit. Optimizely events API.


def __init__(self, url, params, http_verb=None, headers=None):
self.url = url
self.params = params
self.http_verb = http_verb or 'GET'
Copy link
Contributor

Choose a reason for hiding this comment

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

nit. Let's start defaulting to POST. It used to be GET long ago.

self.headers = headers
94 changes: 94 additions & 0 deletions optimizely/event/payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright 2019 Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json


class EventBatch(object):
""" Class respresenting Event Batch. """

def __init__(self, account_id, project_id, revision, client_name, client_version,
anonymize_ip, enrich_decisions, visitors=None):
self.account_id = account_id
self.project_id = project_id
self.revision = revision
self.client_name = client_name
self.client_version = client_version
self.anonymize_ip = anonymize_ip
self.enrich_decisions = enrich_decisions
self.visitors = visitors

def __eq__(self, other):
batch_obj = json.loads(json.dumps(self.__dict__, default=lambda o: o.__dict__),
object_pairs_hook=self._dict_clean)
return batch_obj == other

def _dict_clean(self, obj):
""" Helper method to remove keys from dictionary with None values. """

result = {}
for k, v in obj:
if v is None and k in ['revenue', 'value', 'tags', 'decisions']:
continue
else:
result[k] = v
return result


class Decision(object):
""" Class respresenting Decision. """

def __init__(self, campaign_id, experiment_id, variation_id):
self.campaign_id = campaign_id
self.experiment_id = experiment_id
self.variation_id = variation_id


class Snapshot(object):
""" Class representing Snapshot. """

def __init__(self, events, decisions=None):
self.events = events
self.decisions = decisions


class SnapshotEvent(object):
""" Class representing Snapshot Event. """

def __init__(self, entity_id, uuid, key, timestamp, revenue=None, value=None, tags=None):
self.entity_id = entity_id
self.uuid = uuid
self.key = key
self.timestamp = timestamp
self.revenue = revenue
self.value = value
self.tags = tags


class Visitor(object):
""" Class representing Visitor. """

def __init__(self, snapshots, attributes, visitor_id):
self.snapshots = snapshots
self.attributes = attributes
self.visitor_id = visitor_id


class VisitorAttribute(object):
""" Class representing Visitor Attribute. """

def __init__(self, entity_id, key, attribute_type, value):
self.entity_id = entity_id
self.key = key
self.type = attribute_type
self.value = value
72 changes: 72 additions & 0 deletions optimizely/event/user_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2019 Optimizely
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import time
import uuid

from optimizely import version

CLIENT_NAME = 'python-sdk'


class UserEvent(object):
""" Class respresenting User Event. """

def __init__(self, event_context):
self.event_context = event_context
self.uuid = self._get_uuid()
self.timestamp = self._get_time()

def _get_time(self):
return int(round(time.time() * 1000))

def _get_uuid(self):
return str(uuid.uuid4())


class ImpressionEvent(UserEvent):
""" Class representing Impression Event. """

def __init__(self, event_context, user_id, experiment, visitor_attributes, variation, bot_filtering=None):
super(ImpressionEvent, self).__init__(event_context)
self.event_context = event_context
self.user_id = user_id
self.experiment = experiment
self.visitor_attributes = visitor_attributes
self.variation = variation
self.bot_filtering = bot_filtering


class ConversionEvent(UserEvent):
""" Class representing Conversion Event. """

def __init__(self, event_context, event, user_id, visitor_attributes, event_tags, bot_filtering=None):
super(ConversionEvent, self).__init__(event_context)
self.event_context = event_context
self.event = event
self.user_id = user_id
self.visitor_attributes = visitor_attributes
self.event_tags = event_tags
self.bot_filtering = bot_filtering


class EventContext(object):
""" Class respresenting User Event Context. """

def __init__(self, account_id, project_id, revision, anonymize_ip):
self.account_id = account_id
self.project_id = project_id
self.revision = revision
self.client_name = CLIENT_NAME
self.client_version = version.__version__
self.anonymize_ip = anonymize_ip