Skip to content

Commit

Permalink
refactor: do batched requests for braze tracking in lic assignment
Browse files Browse the repository at this point in the history
  • Loading branch information
iloveagent57 committed Jan 25, 2024
1 parent dbfd551 commit 01bde17
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 79 deletions.
4 changes: 2 additions & 2 deletions license_manager/apps/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,7 +903,7 @@ def send_utilization_threshold_reached_email_task(subscription_uuid):


@shared_task(base=LoggedTaskWithRetry, soft_time_limit=SOFT_TIME_LIMIT, time_limit=MAX_TIME_LIMIT, bind=True)
def track_license_changes_task(self, license_uuids, event_name, properties=None):
def track_license_changes_task(self, license_uuids, event_name, properties=None, is_batch_assignment=False):
"""
Calls ``track_license_changes()`` on some chunks of licenses.
Expand All @@ -921,7 +921,7 @@ def track_license_changes_task(self, license_uuids, event_name, properties=None)
for uuid_str_chunk in chunks(license_uuids, 10):
license_uuid_chunk = [uuid.UUID(uuid_str) for uuid_str in uuid_str_chunk]
licenses = License.objects.filter(uuid__in=license_uuid_chunk)
track_license_changes(licenses, event_name, properties)
track_license_changes(licenses, event_name, properties, is_batch_assignment)
logger.info('Task {} tracked license changes for license uuids {}'.format(
self.request.id,
license_uuid_chunk,
Expand Down
26 changes: 17 additions & 9 deletions license_manager/apps/api/v1/tests/test_api_eventing.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,21 @@ def test_assign_dedupe_eventing(self, _, __):
self._create_available_licenses()
user_emails = [self.test_email, self.test_email]

with mock.patch('license_manager.apps.subscriptions.event_utils.track_event') as mock_assign_track_event:
with mock.patch(
'license_manager.apps.subscriptions.event_utils._track_batch_events_via_braze_alias'
) as mock_batch_braze_track:
response = self.api_client.post(
self.assign_url,
{'greeting': self.greeting, 'closing': self.closing, 'user_emails': user_emails},
)
# We should only have fired an event for 1 assignment:
assert mock_assign_track_event.call_count == 1
for call in mock_assign_track_event.call_args_list:
assert call[0][1] == constants.SegmentEvents.LICENSE_ASSIGNED

assert response.status_code == status.HTTP_200_OK

# We should only have fired an event for 1 assignment:
assert mock_batch_braze_track.call_count == 1
actual_event_name, actual_properties_by_email = mock_batch_braze_track.call_args_list[0][0]
self.assertEqual(actual_event_name, constants.SegmentEvents.LICENSE_ASSIGNED)
self.assertEqual(list(actual_properties_by_email.keys()), [self.test_email])
self._assert_licenses_assigned([self.test_email])

@ddt.data(True, False)
Expand All @@ -114,16 +119,19 @@ def test_assign_eventing(self, use_superuser, _, __):
for call in mock_create_track_event.call_args_list:
assert call[0][1] == constants.SegmentEvents.LICENSE_CREATED

with mock.patch('license_manager.apps.subscriptions.event_utils.track_event') as mock_assign_track_event:
with mock.patch(
'license_manager.apps.subscriptions.event_utils._track_batch_events_via_braze_alias'
) as mock_batch_braze_track:
user_emails = ['bb8@mit.edu', self.test_email]
response = self.api_client.post(
self.assign_url,
{'greeting': self.greeting, 'closing': self.closing, 'user_emails': user_emails},
)
assert response.status_code == status.HTTP_200_OK
assert mock_assign_track_event.call_count == 2
for call in mock_assign_track_event.call_args_list:
assert call[0][1] == constants.SegmentEvents.LICENSE_ASSIGNED
assert mock_batch_braze_track.call_count == 1
actual_event_name, actual_properties_by_email = mock_batch_braze_track.call_args_list[0][0]
self.assertEqual(actual_event_name, constants.SegmentEvents.LICENSE_ASSIGNED)
self.assertCountEqual(list(actual_properties_by_email.keys()), user_emails)

@mock.patch('license_manager.apps.api.v1.views.link_learners_to_enterprise_task.si')
@mock.patch('license_manager.apps.api.v1.views.send_assignment_email_task.si')
Expand Down
8 changes: 5 additions & 3 deletions license_manager/apps/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,12 +688,13 @@ def _link_and_notify_assigned_emails(
pending_learner_batch,
subscription_plan.enterprise_customer_uuid,
),
create_braze_aliases_task.si(
pending_learner_batch
)
)

if notify_users and not disable_onboarding_notifications:
# Braze aliases must be created before we attempt to send assignment emails.
tasks.link(
create_braze_aliases_task.si(pending_learner_batch),
)
tasks.link(
send_assignment_email_task.si(
custom_template_text,
Expand Down Expand Up @@ -851,6 +852,7 @@ def _assign(self, request, subscription_plan):
track_license_changes_task.delay(
[str(_license.uuid) for _license in assigned_licenses],
constants.SegmentEvents.LICENSE_ASSIGNED,
is_batch_assignment=True,
)

self._link_and_notify_assigned_emails(
Expand Down
166 changes: 105 additions & 61 deletions license_manager/apps/subscriptions/event_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Utility methods for sending events to Braze or Segment.
"""
import logging
import uuid

import analytics
import requests
Expand Down Expand Up @@ -29,64 +30,100 @@ def _iso_8601_format_string(datetime):
return datetime.strftime('%Y-%m-%dT%H:%M:%SZ')


def _track_event_via_braze_alias(email, event_name, properties):
""" Private helper to allow tracking for a user without an LMS User Id.
Should be called from inside the track_event module only for exception handling.
def _get_braze_alias(email):
return {
"alias_name": email,
"alias_label": ENTERPRISE_BRAZE_ALIAS_LABEL,
}


def _get_braze_event(braze_alias, event_name, properties):
return {
"user_alias": braze_alias,
"name": event_name,
"time": _iso_8601_format_string(localized_utcnow()),
"properties": properties,
"_update_existing_only": False,
}


def _get_braze_attributes(email, braze_alias):
# we want an email & is_enterprise_learner attribute
# we want _update_existing_only=False so we create a new profile if needed
return {
"user_alias": braze_alias,
"email": email,
"is_enterprise_learner": True,
"_update_existing_only": False,
}


def _profile_attributes_from_properties(properties):
"""
try:
braze_client_instance = BrazeApiClient()
Gather event properties that should eventually be copied
into the braze user profile (associated with an alias).
"""
event_properites_to_copy_to_profile = [
'enterprise_customer_uuid',
'enterprise_customer_slug',
'enterprise_customer_name',
'license_uuid',
'license_activation_key',
]

# first create an alias for this email address with the ent label
braze_client_instance.create_braze_alias(
[email],
ENTERPRISE_BRAZE_ALIAS_LABEL,
)
logger.info('Added alias for pending learner to Braze for license {}, enterprise {}.'
.format(properties['license_uuid'],
properties['enterprise_customer_slug']))
profile_attributes = {}

user_alias = {
"alias_name": email,
"alias_label": ENTERPRISE_BRAZE_ALIAS_LABEL,
}
for event_property in event_properites_to_copy_to_profile:
event_value = properties.get(event_property)
if event_value is not None:
profile_attributes[event_property] = event_value

# we want an email & is_enterprise_learner attribute
# we want _update_existing_only=False so we create a new profile if needed
attributes = {
"user_alias": user_alias,
"email": email,
"is_enterprise_learner": True,
"_update_existing_only": False,
}
return profile_attributes

events = {
"user_alias": user_alias,
"name": event_name,
"time": _iso_8601_format_string(localized_utcnow()),
"properties": properties,
"_update_existing_only": False,
}

# event-level properties are not always available for personalization in braze
# we'll copy these specific event properties into the profile attributes if we see them
event_properites_to_copy_to_profile = [
'enterprise_customer_uuid',
'enterprise_customer_slug',
'enterprise_customer_name',
'license_uuid',
'license_activation_key',
]

for event_property in event_properites_to_copy_to_profile:
if properties.get(event_property) is not None:
attributes[event_property] = properties.get(event_property)

braze_client_instance.track_user(attributes=[attributes], events=[events])
logger.info('Sent "{}" event to Braze for license {}, enterprise {}.'
.format(event_name,
properties['license_uuid'],
properties['enterprise_customer_slug']))
def _track_batch_events_via_braze_alias(event_name, properties_by_email):
"""
Allows batch tracking of users without an lms user id.
"""
braze_alias_emails = []
braze_attributes = []
braze_events = []

# synthetic batch id to help us correlate log messages
batch_id = uuid.uuid4()

for email, properties in properties_by_email.items():
# Create an alias and stash the email in a list we'll send as a batch to braze via `create_braze_alias()`
braze_alias_emails.append(email)
user_alias = _get_braze_alias(email)

# Create an attribute record and stash in a list we'll send to braze via `track_user()`.
attribute_record = _get_braze_attributes(email, user_alias)
attribute_record.update(_profile_attributes_from_properties(properties))
braze_attributes.append(attribute_record)

# Create an event record and stash in a list we'll send to braze via `track_user()`.
event_record = _get_braze_event(user_alias, event_name, properties)
braze_events.append(event_record)

msg = 'Added braze alias/attribute/event to batch %s for pending learner with license %s, enterprise %s.'
logger.info(msg, batch_id, properties['license_uuid'], properties['enterprise_customer_slug'])

# Now send the data to braze
braze_client_instance = BrazeApiClient()
try:
braze_client_instance.create_braze_alias(braze_alias_emails, ENTERPRISE_BRAZE_ALIAS_LABEL)
logger.info('Sent batch of braze aliases with batch id %s', batch_id)
except BrazeClientError as exc:
logger.exception()
raise exc

try:
braze_client_instance.track_user(
attributes=braze_attributes,
events=braze_events,
)
logger.info('Sent batch of braze attribute/events to track_user endpoint with batch id %s', batch_id)
except BrazeClientError as exc:
logger.exception()
raise exc
Expand Down Expand Up @@ -164,8 +201,9 @@ def track_event(lms_user_id, event_name, properties):
logger.warning(
"Event {} for License Manager tracked without LMS User Id: {}".format(event_name, properties)
)
if properties['assigned_email']:
_track_event_via_braze_alias(properties['assigned_email'], event_name, properties)
assigned_email = properties['assigned_email']
if assigned_email:
_track_batch_events_via_braze_alias(event_name, {assigned_email: properties})
else:
analytics.track(user_id=lms_user_id, event=event_name, properties=properties)
except Exception as exc: # pylint: disable=broad-except
Expand Down Expand Up @@ -218,7 +256,7 @@ def get_license_tracking_properties(license_obj):
return license_data


def track_license_changes(licenses, event_name, properties=None):
def track_license_changes(licenses, event_name, properties=None, is_batch_assignment=False):
"""
Send tracking events for changes to a list of licenses, useful when bulk changes are made.
Prefetches related objects for licenses to prevent additional queries
Expand All @@ -231,18 +269,24 @@ def track_license_changes(licenses, event_name, properties=None):
overrides fields from get_license_tracking_properties
Returns:
None
if ``is_batch_assignment``, call `track_users` in braze with a list of users to alias and track, skip
over the normal `track_event()` call.
"""
properties = properties or {}
# prefetch related objects used in get_license_tracking_properties
prefetch_related_objects(licenses, '_renewed_from', 'subscription_plan', 'subscription_plan__customer_agreement')

for lcs in licenses:
event_properties = {**get_license_tracking_properties(lcs), **properties}
track_event(
lcs.lms_user_id, # None for unassigned licenses, track_event will handle users with unregistered emails
event_name,
event_properties,
)
if is_batch_assignment:
properties_by_email = {
lcs.user_email: {**get_license_tracking_properties(lcs), **properties}
for lcs in licenses
}
_track_batch_events_via_braze_alias(event_name, properties_by_email)
else:
for lcs in licenses:
event_properties = {**get_license_tracking_properties(lcs), **properties}
track_event(lcs.lms_user_id, event_name, event_properties)


def get_enterprise_tracking_properties(customer_agreement):
Expand Down
8 changes: 4 additions & 4 deletions license_manager/apps/subscriptions/tests/test_event_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
)
from license_manager.apps.subscriptions.event_utils import (
_iso_8601_format_string,
_track_event_via_braze_alias,
_track_batch_events_via_braze_alias,
get_license_tracking_properties,
track_license_changes,
)
Expand Down Expand Up @@ -113,6 +113,6 @@ def test_track_event_via_braze_alias(mock_braze_client):
"properties": test_event_properties,
"_update_existing_only": False,
}
_track_event_via_braze_alias(test_email, test_event_name, test_event_properties)
mock_braze_client().create_braze_alias.assert_any_call([test_email], ENTERPRISE_BRAZE_ALIAS_LABEL)
mock_braze_client().track_user.assert_any_call(attributes=[expected_attributes], events=[expected_event])
_track_batch_events_via_braze_alias(test_event_name, {test_email: test_event_properties})
mock_braze_client.return_value.create_braze_alias.assert_any_call([test_email], ENTERPRISE_BRAZE_ALIAS_LABEL)
mock_braze_client.return_value.track_user.assert_any_call(attributes=[expected_attributes], events=[expected_event])

0 comments on commit 01bde17

Please sign in to comment.