Skip to content

Commit 756980d

Browse files
Add event deduplication via id and timestamp params (#125)
* Add event deduplication via id and timestamp params Change track() and track_anonymous() to accept a data dict instead of **kwargs, with optional id and timestamp keyword arguments. The id parameter accepts a ULID for event deduplication. The timestamp parameter sets the event time (epoch seconds). Invalid timestamps are silently dropped. Passing data=None sends an empty data dict. This is a breaking change: callers must switch from keyword arguments to a dict for event data attributes. Refs #98
1 parent b0e6dac commit 756980d

4 files changed

Lines changed: 247 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
### Added
5+
- Add support for optional top-level `id` and `timestamp` event fields in `track()` and `track_anonymous()`.
6+
7+
### Changed
8+
- `track()` and `track_anonymous()` now take custom event attributes in the `data` dict instead of arbitrary keyword arguments.
9+
310
## [2.4]
411
### Added
512
- Add support for sending transactional in-app messages [#113](https://github.com/customerio/customerio-python/pull/113)

README.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ from customerio import CustomerIO, Regions
2828
cio = CustomerIO(site_id, api_key, region=Regions.US)
2929
cio.identify(id="5", email='customer@example.com', name='Bob', plan='premium')
3030
cio.track(customer_id="5", name='purchased')
31-
cio.track(customer_id="5", name='purchased', price=23.45)
31+
cio.track(customer_id="5", name='purchased', data={"price": 23.45})
3232
```
3333

3434
### Instantiating customer.io object
@@ -51,7 +51,7 @@ Only the id field is used to identify the customer here. Using an existing id w
5151
a different email (or any other attribute) will update/overwrite any pre-existing
5252
values for that field.
5353

54-
You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes.
54+
You can pass any keyword arguments to the `identify` method. These kwargs will be converted to custom attributes.
5555

5656
See original REST documentation [here](http://customer.io/docs/api/track/#operation/identify)
5757

@@ -64,13 +64,27 @@ cio.track(customer_id="5", name='purchased')
6464
### Track a custom event with custom data values
6565

6666
```python
67-
cio.track(customer_id="5", name='purchased', price=23.45, product="widget")
67+
cio.track(customer_id="5", name='purchased', data={"price": 23.45, "product": "widget"})
6868
```
6969

70-
You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes.
70+
Pass custom event attributes to `track` in the `data` dict.
7171

7272
See original REST documentation [here](http://customer.io/docs/api/track/#operation/track)
7373

74+
### Track a custom event with an event id or timestamp
75+
76+
```python
77+
cio.track(
78+
customer_id="5",
79+
name='purchased',
80+
data={"price": 23.45, "product": "widget"},
81+
id="01HB4HBDKTFWYZCK01DMRSWRFD",
82+
timestamp=1561231234
83+
)
84+
```
85+
86+
Pass `id` to provide a unique event identifier for deduplication. Pass `timestamp` to set the event time. These fields are sent as top-level event fields, not as custom attributes in `data`.
87+
7488
### Backfill a custom event
7589

7690
```python
@@ -92,24 +106,34 @@ cio.backfill(customer_id, event_type, event_timestamp, price=45.67)
92106

93107
Event timestamp may be passed as a ```datetime.datetime``` object, an integer or a string UNIX timestamp
94108

95-
Keyword arguments to backfill work the same as a call to ```cio.track```.
109+
Keyword arguments to backfill are converted to custom event attributes.
96110

97111
See original REST documentation [here](http://customer.io/docs/api/track/#operation/track)
98112

99113
### Track an anonymous event
100114

101115
```python
102-
cio.track_anonymous(anonymous_id="anon-event", name="purchased", price=23.45, product="widget")
116+
cio.track_anonymous(
117+
anonymous_id="anon-event",
118+
name="purchased",
119+
data={"price": 23.45, "product": "widget"}
120+
)
103121
```
104122

105123
An anonymous event is an event associated with a person you haven't identified. The event requires an `anonymous_id` representing the unknown person and an event `name`. When you identify a person, you can set their `anonymous_id` attribute. If [event merging](https://customer.io/docs/anonymous-events/#turn-on-merging) is turned on in your workspace, and the attribute matches the `anonymous_id` in one or more events that were logged within the last 30 days, we associate those events with the person.
106124

125+
Like `track`, `track_anonymous` accepts custom event attributes in `data` and optional top-level `id` and `timestamp` fields.
126+
107127
#### Anonymous invite events
108128

109129
If you previously sent [invite events](https://customer.io/docs/journeys/anonymous-invite-emails/), you can achieve the same functionality by sending an anonymous event with the anonymous identifier set to `None`. To send anonymous invites, your event *must* include a `recipient` attribute.
110130

111131
```python
112-
cio.track_anonymous(anonymous_id=None, name="invite", first_name="alex", recipient="alex.person@example.com")
132+
cio.track_anonymous(
133+
anonymous_id=None,
134+
name="invite",
135+
data={"first_name": "alex", "recipient": "alex.person@example.com"}
136+
)
113137
```
114138

115139
### Delete a customer profile
@@ -124,7 +148,7 @@ This method returns nothing. Attempts to delete non-existent customers will not
124148
See original REST documentation [here](https://customer.io/docs/api/track/#operation/delete)
125149

126150

127-
You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes.
151+
You can pass any keyword arguments to the `identify` method. These kwargs will be converted to custom attributes.
128152

129153
### Merge duplicate customer profiles
130154

customerio/track.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,18 @@ def identify(self, id, **kwargs):
9191
url = self.get_customer_query_string(id)
9292
return self.send_request("PUT", url, kwargs)
9393

94-
def track(self, customer_id, name, **data):
94+
def track(self, customer_id, name, data=None, id=None, timestamp=None):
9595
"""Track an event for a given customer_id."""
9696
if not customer_id:
9797
raise CustomerIOException("customer_id cannot be blank in track")
9898
url = self.get_event_query_string(customer_id)
99-
post_data = {
100-
"name": name,
101-
"data": self._sanitize(data),
102-
}
99+
post_data = self._build_event(name, data, id=id, timestamp=timestamp)
103100
return self.send_request("POST", url, post_data)
104101

105-
def track_anonymous(self, anonymous_id, name, **data):
102+
def track_anonymous(self, anonymous_id, name, data=None, id=None, timestamp=None):
106103
"""Track an event for a given anonymous_id."""
107104
url = self.get_events_query_string()
108-
post_data = {
109-
"name": name,
110-
"data": self._sanitize(data),
111-
}
105+
post_data = self._build_event(name, data, id=id, timestamp=timestamp)
112106
if anonymous_id:
113107
post_data["anonymous_id"] = anonymous_id
114108

@@ -149,6 +143,27 @@ def backfill(self, customer_id, name, timestamp, **data):
149143

150144
return self.send_request("POST", url, post_data)
151145

146+
def _build_event(self, name, data=None, id=None, timestamp=None):
147+
post_data = {
148+
"name": name,
149+
"data": self._sanitize(data or {}),
150+
}
151+
if id is not None:
152+
post_data["id"] = id
153+
if timestamp is not None:
154+
if isinstance(timestamp, datetime):
155+
timestamp = self._datetime_to_timestamp(timestamp)
156+
elif isinstance(timestamp, int):
157+
pass
158+
else:
159+
try:
160+
timestamp = int(timestamp)
161+
except (ValueError, TypeError, OverflowError):
162+
timestamp = None
163+
if timestamp is not None:
164+
post_data["timestamp"] = timestamp
165+
return post_data
166+
152167
def delete(self, customer_id):
153168
"""Delete a customer profile."""
154169
if not customer_id:

tests/test_customerio.py

Lines changed: 183 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@
1717
urllib3.disable_warnings()
1818

1919

20+
class TestCustomerIOTrackSignatures(unittest.TestCase):
21+
def setUp(self):
22+
self.cio = CustomerIO(site_id="siteid", api_key="apikey")
23+
24+
def test_track_rejects_event_data_keyword_arguments(self):
25+
with self.assertRaises(TypeError):
26+
self.cio.track(customer_id="5", name="purchased", price=23.45)
27+
28+
def test_track_anonymous_rejects_event_data_keyword_arguments(self):
29+
with self.assertRaises(TypeError):
30+
self.cio.track_anonymous(
31+
anonymous_id=None,
32+
name="invite",
33+
recipient="alex.person@example.com",
34+
)
35+
36+
2037
class TestCustomerIO(HTTPSTestCase):
2138
"""Starts server which the client connects to in the following tests"""
2239

@@ -131,11 +148,110 @@ def test_track_call(self):
131148
)
132149
)
133150

134-
self.cio.track(customer_id=1, name="sign_up", email="john@test.com")
151+
self.cio.track(customer_id=1, name="sign_up", data={"email": "john@test.com"})
135152

136153
with self.assertRaises(TypeError):
137154
self.cio.track(random_attr="some_value")
138155

156+
def test_track_with_id(self):
157+
self.cio.http.hooks = dict(
158+
response=partial(
159+
self._check_request,
160+
rq={
161+
"method": "POST",
162+
"url_suffix": "/customers/1/events",
163+
"body": {
164+
"name": "purchase",
165+
"data": {"type": "socks"},
166+
"id": "01HB4HBDKTFWYZCK01DMRSWRFD",
167+
},
168+
},
169+
)
170+
)
171+
172+
self.cio.track(1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD")
173+
174+
def test_track_without_id(self):
175+
self.cio.http.hooks = dict(
176+
response=partial(
177+
self._check_request,
178+
rq={
179+
"method": "POST",
180+
"url_suffix": "/customers/1/events",
181+
"body": {"name": "purchase", "data": {"type": "socks"}},
182+
},
183+
)
184+
)
185+
186+
self.cio.track(1, "purchase", {"type": "socks"})
187+
188+
def test_track_with_timestamp(self):
189+
self.cio.http.hooks = dict(
190+
response=partial(
191+
self._check_request,
192+
rq={
193+
"method": "POST",
194+
"url_suffix": "/customers/1/events",
195+
"body": {
196+
"name": "purchase",
197+
"data": {"type": "socks"},
198+
"timestamp": 1561231234,
199+
},
200+
},
201+
)
202+
)
203+
204+
self.cio.track(1, "purchase", {"type": "socks"}, timestamp=1561231234)
205+
206+
def test_track_with_id_and_timestamp(self):
207+
self.cio.http.hooks = dict(
208+
response=partial(
209+
self._check_request,
210+
rq={
211+
"method": "POST",
212+
"url_suffix": "/customers/1/events",
213+
"body": {
214+
"name": "purchase",
215+
"data": {"type": "socks"},
216+
"id": "01HB4HBDKTFWYZCK01DMRSWRFD",
217+
"timestamp": 1561231234,
218+
},
219+
},
220+
)
221+
)
222+
223+
self.cio.track(
224+
1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD", timestamp=1561231234
225+
)
226+
227+
def test_track_with_invalid_timestamp(self):
228+
self.cio.http.hooks = dict(
229+
response=partial(
230+
self._check_request,
231+
rq={
232+
"method": "POST",
233+
"url_suffix": "/customers/1/events",
234+
"body": {"name": "purchase", "data": {"type": "socks"}},
235+
},
236+
)
237+
)
238+
239+
self.cio.track(1, "purchase", {"type": "socks"}, timestamp="not-a-timestamp")
240+
241+
def test_track_with_no_data(self):
242+
self.cio.http.hooks = dict(
243+
response=partial(
244+
self._check_request,
245+
rq={
246+
"method": "POST",
247+
"url_suffix": "/customers/1/events",
248+
"body": {"name": "login", "data": {}},
249+
},
250+
)
251+
)
252+
253+
self.cio.track(1, "login")
254+
139255
def test_track_anonymous_call(self):
140256
self.cio.http.hooks = dict(
141257
response=partial(
@@ -154,7 +270,71 @@ def test_track_anonymous_call(self):
154270
)
155271
)
156272

157-
self.cio.track_anonymous(anonymous_id=123, name="sign_up", email="john@test.com")
273+
self.cio.track_anonymous(anonymous_id=123, name="sign_up", data={"email": "john@test.com"})
274+
275+
def test_track_anonymous_invite_with_data_dict(self):
276+
self.cio.http.hooks = dict(
277+
response=partial(
278+
self._check_request,
279+
rq={
280+
"method": "POST",
281+
"authorization": _basic_auth_str("siteid", "apikey"),
282+
"content_type": "application/json",
283+
"url_suffix": "/events",
284+
"body": {
285+
"data": {
286+
"first_name": "alex",
287+
"recipient": "alex.person@example.com",
288+
},
289+
"name": "invite",
290+
},
291+
},
292+
)
293+
)
294+
295+
self.cio.track_anonymous(
296+
anonymous_id=None,
297+
name="invite",
298+
data={"first_name": "alex", "recipient": "alex.person@example.com"},
299+
)
300+
301+
def test_track_anonymous_with_id(self):
302+
self.cio.http.hooks = dict(
303+
response=partial(
304+
self._check_request,
305+
rq={
306+
"method": "POST",
307+
"url_suffix": "/events",
308+
"body": {
309+
"name": "purchase",
310+
"data": {},
311+
"anonymous_id": "anon-123",
312+
"id": "01HB4HBDKTFWYZCK01DMRSWRFD",
313+
},
314+
},
315+
)
316+
)
317+
318+
self.cio.track_anonymous("anon-123", "purchase", id="01HB4HBDKTFWYZCK01DMRSWRFD")
319+
320+
def test_track_anonymous_with_timestamp(self):
321+
self.cio.http.hooks = dict(
322+
response=partial(
323+
self._check_request,
324+
rq={
325+
"method": "POST",
326+
"url_suffix": "/events",
327+
"body": {
328+
"name": "purchase",
329+
"data": {"type": "socks"},
330+
"anonymous_id": "anon-123",
331+
"timestamp": 1561231234,
332+
},
333+
},
334+
)
335+
)
336+
337+
self.cio.track_anonymous("anon-123", "purchase", {"type": "socks"}, timestamp=1561231234)
158338

159339
def test_pageview_call(self):
160340
self.cio.http.hooks = dict(
@@ -417,7 +597,7 @@ def test_ids_are_encoded_in_url(self):
417597
},
418598
)
419599
)
420-
self.cio.track(customer_id="1 ", name="test")
600+
self.cio.track(customer_id="1 ", name="test", data={})
421601

422602
self.cio.http.hooks = dict(
423603
response=partial(

0 commit comments

Comments
 (0)