From 4efee3b2a743e39bcf200bb75909955bf861e109 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 23:35:07 -0400 Subject: [PATCH 1/4] 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 --- customerio/track.py | 35 +++++++--- tests/test_customerio.py | 145 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 13 deletions(-) diff --git a/customerio/track.py b/customerio/track.py index c183484..c210715 100644 --- a/customerio/track.py +++ b/customerio/track.py @@ -91,24 +91,18 @@ def identify(self, id, **kwargs): url = self.get_customer_query_string(id) return self.send_request("PUT", url, kwargs) - def track(self, customer_id, name, **data): + def track(self, customer_id, name, data=None, id=None, timestamp=None): """Track an event for a given customer_id.""" if not customer_id: raise CustomerIOException("customer_id cannot be blank in track") url = self.get_event_query_string(customer_id) - post_data = { - "name": name, - "data": self._sanitize(data), - } + post_data = self._build_event(name, data, id=id, timestamp=timestamp) return self.send_request("POST", url, post_data) - def track_anonymous(self, anonymous_id, name, **data): + def track_anonymous(self, anonymous_id, name, data=None, id=None, timestamp=None): """Track an event for a given anonymous_id.""" url = self.get_events_query_string() - post_data = { - "name": name, - "data": self._sanitize(data), - } + post_data = self._build_event(name, data, id=id, timestamp=timestamp) if anonymous_id: post_data["anonymous_id"] = anonymous_id @@ -149,6 +143,27 @@ def backfill(self, customer_id, name, timestamp, **data): return self.send_request("POST", url, post_data) + def _build_event(self, name, data=None, id=None, timestamp=None): + post_data = { + "name": name, + "data": self._sanitize(data or {}), + } + if id is not None: + post_data["id"] = id + if timestamp is not None: + if isinstance(timestamp, datetime): + timestamp = self._datetime_to_timestamp(timestamp) + elif isinstance(timestamp, int): + pass + else: + try: + timestamp = int(timestamp) + except (ValueError, TypeError): + timestamp = None + if timestamp is not None: + post_data["timestamp"] = timestamp + return post_data + def delete(self, customer_id): """Delete a customer profile.""" if not customer_id: diff --git a/tests/test_customerio.py b/tests/test_customerio.py index 698802d..b886fb5 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -108,11 +108,110 @@ def test_track_call(self): ) ) - self.cio.track(customer_id=1, name="sign_up", email="john@test.com") + self.cio.track(customer_id=1, name="sign_up", data={"email": "john@test.com"}) with self.assertRaises(TypeError): self.cio.track(random_attr="some_value") + def test_track_with_id(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/customers/1/events", + "body": { + "name": "purchase", + "data": {"type": "socks"}, + "id": "01HB4HBDKTFWYZCK01DMRSWRFD", + }, + }, + ) + ) + + self.cio.track(1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD") + + def test_track_without_id(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/customers/1/events", + "body": {"name": "purchase", "data": {"type": "socks"}}, + }, + ) + ) + + self.cio.track(1, "purchase", {"type": "socks"}) + + def test_track_with_timestamp(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/customers/1/events", + "body": { + "name": "purchase", + "data": {"type": "socks"}, + "timestamp": 1561231234, + }, + }, + ) + ) + + self.cio.track(1, "purchase", {"type": "socks"}, timestamp=1561231234) + + def test_track_with_id_and_timestamp(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/customers/1/events", + "body": { + "name": "purchase", + "data": {"type": "socks"}, + "id": "01HB4HBDKTFWYZCK01DMRSWRFD", + "timestamp": 1561231234, + }, + }, + ) + ) + + self.cio.track( + 1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD", timestamp=1561231234 + ) + + def test_track_with_invalid_timestamp(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/customers/1/events", + "body": {"name": "purchase", "data": {"type": "socks"}}, + }, + ) + ) + + self.cio.track(1, "purchase", {"type": "socks"}, timestamp="not-a-timestamp") + + def test_track_with_no_data(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/customers/1/events", + "body": {"name": "login", "data": {}}, + }, + ) + ) + + self.cio.track(1, "login") + def test_track_anonymous_call(self): self.cio.http.hooks = dict( response=partial( @@ -131,7 +230,47 @@ def test_track_anonymous_call(self): ) ) - self.cio.track_anonymous(anonymous_id=123, name="sign_up", email="john@test.com") + self.cio.track_anonymous(anonymous_id=123, name="sign_up", data={"email": "john@test.com"}) + + def test_track_anonymous_with_id(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/events", + "body": { + "name": "purchase", + "data": {}, + "anonymous_id": "anon-123", + "id": "01HB4HBDKTFWYZCK01DMRSWRFD", + }, + }, + ) + ) + + self.cio.track_anonymous("anon-123", "purchase", id="01HB4HBDKTFWYZCK01DMRSWRFD") + + def test_track_anonymous_with_timestamp(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "url_suffix": "/events", + "body": { + "name": "purchase", + "data": {"type": "socks"}, + "anonymous_id": "anon-123", + "timestamp": 1561231234, + }, + }, + ) + ) + + self.cio.track_anonymous( + "anon-123", "purchase", {"type": "socks"}, timestamp=1561231234 + ) def test_pageview_call(self): self.cio.http.hooks = dict( @@ -394,7 +533,7 @@ def test_ids_are_encoded_in_url(self): }, ) ) - self.cio.track(customer_id="1 ", name="test") + self.cio.track(customer_id="1 ", name="test", data={}) self.cio.http.hooks = dict( response=partial( From 2ad3e0ca8bb4abddd568517cc9285d743703fdd6 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 23:43:04 -0400 Subject: [PATCH 2/4] Fix formatting in test file --- tests/test_customerio.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_customerio.py b/tests/test_customerio.py index b886fb5..9b26f40 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -268,9 +268,7 @@ def test_track_anonymous_with_timestamp(self): ) ) - self.cio.track_anonymous( - "anon-123", "purchase", {"type": "socks"}, timestamp=1561231234 - ) + self.cio.track_anonymous("anon-123", "purchase", {"type": "socks"}, timestamp=1561231234) def test_pageview_call(self): self.cio.http.hooks = dict( From 8e259a06ace9af55fe43116562a1440074d194ff Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 23:44:19 -0400 Subject: [PATCH 3/4] Catch OverflowError for infinity timestamp values --- customerio/track.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/customerio/track.py b/customerio/track.py index c210715..18505bd 100644 --- a/customerio/track.py +++ b/customerio/track.py @@ -158,7 +158,7 @@ def _build_event(self, name, data=None, id=None, timestamp=None): else: try: timestamp = int(timestamp) - except (ValueError, TypeError): + except (ValueError, TypeError, OverflowError): timestamp = None if timestamp is not None: post_data["timestamp"] = timestamp From e78ab1e635df6ba2c73f7fd992fa3606d662363e Mon Sep 17 00:00:00 2001 From: joeybaer <35610156+joeybaer@users.noreply.github.com> Date: Fri, 8 May 2026 15:41:00 -0500 Subject: [PATCH 4/4] Document track event data API --- CHANGELOG.md | 7 +++++++ README.md | 40 +++++++++++++++++++++++++++++-------- tests/test_customerio.py | 43 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae78b60..eb7e020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased +### Added +- Add support for optional top-level `id` and `timestamp` event fields in `track()` and `track_anonymous()`. + +### Changed +- `track()` and `track_anonymous()` now take custom event attributes in the `data` dict instead of arbitrary keyword arguments. + ## [2.4] ### Added - Add support for sending transactional in-app messages [#113](https://github.com/customerio/customerio-python/pull/113) diff --git a/README.md b/README.md index 50f6f1a..5123cb4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ from customerio import CustomerIO, Regions cio = CustomerIO(site_id, api_key, region=Regions.US) cio.identify(id="5", email='customer@example.com', name='Bob', plan='premium') cio.track(customer_id="5", name='purchased') -cio.track(customer_id="5", name='purchased', price=23.45) +cio.track(customer_id="5", name='purchased', data={"price": 23.45}) ``` ### Instantiating customer.io object @@ -51,7 +51,7 @@ Only the id field is used to identify the customer here. Using an existing id w a different email (or any other attribute) will update/overwrite any pre-existing values for that field. -You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes. +You can pass any keyword arguments to the `identify` method. These kwargs will be converted to custom attributes. See original REST documentation [here](http://customer.io/docs/api/track/#operation/identify) @@ -64,13 +64,27 @@ cio.track(customer_id="5", name='purchased') ### Track a custom event with custom data values ```python -cio.track(customer_id="5", name='purchased', price=23.45, product="widget") +cio.track(customer_id="5", name='purchased', data={"price": 23.45, "product": "widget"}) ``` -You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes. +Pass custom event attributes to `track` in the `data` dict. See original REST documentation [here](http://customer.io/docs/api/track/#operation/track) +### Track a custom event with an event id or timestamp + +```python +cio.track( + customer_id="5", + name='purchased', + data={"price": 23.45, "product": "widget"}, + id="01HB4HBDKTFWYZCK01DMRSWRFD", + timestamp=1561231234 +) +``` + +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`. + ### Backfill a custom event ```python @@ -92,24 +106,34 @@ cio.backfill(customer_id, event_type, event_timestamp, price=45.67) Event timestamp may be passed as a ```datetime.datetime``` object, an integer or a string UNIX timestamp -Keyword arguments to backfill work the same as a call to ```cio.track```. +Keyword arguments to backfill are converted to custom event attributes. See original REST documentation [here](http://customer.io/docs/api/track/#operation/track) ### Track an anonymous event ```python -cio.track_anonymous(anonymous_id="anon-event", name="purchased", price=23.45, product="widget") +cio.track_anonymous( + anonymous_id="anon-event", + name="purchased", + data={"price": 23.45, "product": "widget"} +) ``` 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. +Like `track`, `track_anonymous` accepts custom event attributes in `data` and optional top-level `id` and `timestamp` fields. + #### Anonymous invite events 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. ```python -cio.track_anonymous(anonymous_id=None, name="invite", first_name="alex", recipient="alex.person@example.com") +cio.track_anonymous( + anonymous_id=None, + name="invite", + data={"first_name": "alex", "recipient": "alex.person@example.com"} +) ``` ### Delete a customer profile @@ -124,7 +148,7 @@ This method returns nothing. Attempts to delete non-existent customers will not See original REST documentation [here](https://customer.io/docs/api/track/#operation/delete) -You can pass any keyword arguments to the `identify` and `track` methods. These kwargs will be converted to custom attributes. +You can pass any keyword arguments to the `identify` method. These kwargs will be converted to custom attributes. ### Merge duplicate customer profiles diff --git a/tests/test_customerio.py b/tests/test_customerio.py index 9b26f40..a7f9111 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -14,6 +14,23 @@ urllib3.disable_warnings() +class TestCustomerIOTrackSignatures(unittest.TestCase): + def setUp(self): + self.cio = CustomerIO(site_id="siteid", api_key="apikey") + + def test_track_rejects_event_data_keyword_arguments(self): + with self.assertRaises(TypeError): + self.cio.track(customer_id="5", name="purchased", price=23.45) + + def test_track_anonymous_rejects_event_data_keyword_arguments(self): + with self.assertRaises(TypeError): + self.cio.track_anonymous( + anonymous_id=None, + name="invite", + recipient="alex.person@example.com", + ) + + class TestCustomerIO(HTTPSTestCase): """Starts server which the client connects to in the following tests""" @@ -232,6 +249,32 @@ def test_track_anonymous_call(self): self.cio.track_anonymous(anonymous_id=123, name="sign_up", data={"email": "john@test.com"}) + def test_track_anonymous_invite_with_data_dict(self): + self.cio.http.hooks = dict( + response=partial( + self._check_request, + rq={ + "method": "POST", + "authorization": _basic_auth_str("siteid", "apikey"), + "content_type": "application/json", + "url_suffix": "/events", + "body": { + "data": { + "first_name": "alex", + "recipient": "alex.person@example.com", + }, + "name": "invite", + }, + }, + ) + ) + + self.cio.track_anonymous( + anonymous_id=None, + name="invite", + data={"first_name": "alex", "recipient": "alex.person@example.com"}, + ) + def test_track_anonymous_with_id(self): self.cio.http.hooks = dict( response=partial(