Skip to content

Commit 4efee3b

Browse files
committed
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 7b23a68 commit 4efee3b

2 files changed

Lines changed: 167 additions & 13 deletions

File tree

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):
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: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,110 @@ def test_track_call(self):
108108
)
109109
)
110110

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

113113
with self.assertRaises(TypeError):
114114
self.cio.track(random_attr="some_value")
115115

116+
def test_track_with_id(self):
117+
self.cio.http.hooks = dict(
118+
response=partial(
119+
self._check_request,
120+
rq={
121+
"method": "POST",
122+
"url_suffix": "/customers/1/events",
123+
"body": {
124+
"name": "purchase",
125+
"data": {"type": "socks"},
126+
"id": "01HB4HBDKTFWYZCK01DMRSWRFD",
127+
},
128+
},
129+
)
130+
)
131+
132+
self.cio.track(1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD")
133+
134+
def test_track_without_id(self):
135+
self.cio.http.hooks = dict(
136+
response=partial(
137+
self._check_request,
138+
rq={
139+
"method": "POST",
140+
"url_suffix": "/customers/1/events",
141+
"body": {"name": "purchase", "data": {"type": "socks"}},
142+
},
143+
)
144+
)
145+
146+
self.cio.track(1, "purchase", {"type": "socks"})
147+
148+
def test_track_with_timestamp(self):
149+
self.cio.http.hooks = dict(
150+
response=partial(
151+
self._check_request,
152+
rq={
153+
"method": "POST",
154+
"url_suffix": "/customers/1/events",
155+
"body": {
156+
"name": "purchase",
157+
"data": {"type": "socks"},
158+
"timestamp": 1561231234,
159+
},
160+
},
161+
)
162+
)
163+
164+
self.cio.track(1, "purchase", {"type": "socks"}, timestamp=1561231234)
165+
166+
def test_track_with_id_and_timestamp(self):
167+
self.cio.http.hooks = dict(
168+
response=partial(
169+
self._check_request,
170+
rq={
171+
"method": "POST",
172+
"url_suffix": "/customers/1/events",
173+
"body": {
174+
"name": "purchase",
175+
"data": {"type": "socks"},
176+
"id": "01HB4HBDKTFWYZCK01DMRSWRFD",
177+
"timestamp": 1561231234,
178+
},
179+
},
180+
)
181+
)
182+
183+
self.cio.track(
184+
1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD", timestamp=1561231234
185+
)
186+
187+
def test_track_with_invalid_timestamp(self):
188+
self.cio.http.hooks = dict(
189+
response=partial(
190+
self._check_request,
191+
rq={
192+
"method": "POST",
193+
"url_suffix": "/customers/1/events",
194+
"body": {"name": "purchase", "data": {"type": "socks"}},
195+
},
196+
)
197+
)
198+
199+
self.cio.track(1, "purchase", {"type": "socks"}, timestamp="not-a-timestamp")
200+
201+
def test_track_with_no_data(self):
202+
self.cio.http.hooks = dict(
203+
response=partial(
204+
self._check_request,
205+
rq={
206+
"method": "POST",
207+
"url_suffix": "/customers/1/events",
208+
"body": {"name": "login", "data": {}},
209+
},
210+
)
211+
)
212+
213+
self.cio.track(1, "login")
214+
116215
def test_track_anonymous_call(self):
117216
self.cio.http.hooks = dict(
118217
response=partial(
@@ -131,7 +230,47 @@ def test_track_anonymous_call(self):
131230
)
132231
)
133232

134-
self.cio.track_anonymous(anonymous_id=123, name="sign_up", email="john@test.com")
233+
self.cio.track_anonymous(anonymous_id=123, name="sign_up", data={"email": "john@test.com"})
234+
235+
def test_track_anonymous_with_id(self):
236+
self.cio.http.hooks = dict(
237+
response=partial(
238+
self._check_request,
239+
rq={
240+
"method": "POST",
241+
"url_suffix": "/events",
242+
"body": {
243+
"name": "purchase",
244+
"data": {},
245+
"anonymous_id": "anon-123",
246+
"id": "01HB4HBDKTFWYZCK01DMRSWRFD",
247+
},
248+
},
249+
)
250+
)
251+
252+
self.cio.track_anonymous("anon-123", "purchase", id="01HB4HBDKTFWYZCK01DMRSWRFD")
253+
254+
def test_track_anonymous_with_timestamp(self):
255+
self.cio.http.hooks = dict(
256+
response=partial(
257+
self._check_request,
258+
rq={
259+
"method": "POST",
260+
"url_suffix": "/events",
261+
"body": {
262+
"name": "purchase",
263+
"data": {"type": "socks"},
264+
"anonymous_id": "anon-123",
265+
"timestamp": 1561231234,
266+
},
267+
},
268+
)
269+
)
270+
271+
self.cio.track_anonymous(
272+
"anon-123", "purchase", {"type": "socks"}, timestamp=1561231234
273+
)
135274

136275
def test_pageview_call(self):
137276
self.cio.http.hooks = dict(
@@ -394,7 +533,7 @@ def test_ids_are_encoded_in_url(self):
394533
},
395534
)
396535
)
397-
self.cio.track(customer_id="1 ", name="test")
536+
self.cio.track(customer_id="1 ", name="test", data={})
398537

399538
self.cio.http.hooks = dict(
400539
response=partial(

0 commit comments

Comments
 (0)