From b930f2a8ea37b44183dd0bc8d3c38688a4d7fccc Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 13:01:46 -0400 Subject: [PATCH 1/4] feat: add event deduplication via optional id parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional `id:` keyword argument to `track` and `track_anonymous` for event deduplication. The id should be a ULID. No client-side validation — the API rejects invalid values. Fixes #107 --- README.md | 10 ++++++++++ lib/customerio/client.rb | 21 ++++++++++++--------- spec/client_spec.rb | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8c57e59..cff3da4 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,16 @@ $customerio.track(5, "purchase", :type => "socks", :price => "13.99") $customerio.track(5, "purchase", :type => "socks", :price => "13.99", :timestamp => 1365436200) ``` +#### Deduplicating events + +You can provide a [ULID](https://github.com/ulid/spec) `id` to deduplicate events. If two events have the same `id`, Customer.io won't process the event a second time. + +```ruby +$customerio.track(5, "purchase", { :type => "socks" }, id: "01BX5ZZKBKACTAV9WEVGEMMVRY") + +$customerio.track_anonymous("anon-id", "purchase", { :type => "socks" }, id: "01BX5ZZKBKACTAV9WEVGEMMVRY") +``` + ### Tracking anonymous events You can also send anonymous events, for situations where you don't yet have a customer record yet. An anonymous 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. diff --git a/lib/customerio/client.rb b/lib/customerio/client.rb index 078adc5..50ae4cb 100644 --- a/lib/customerio/client.rb +++ b/lib/customerio/client.rb @@ -52,11 +52,11 @@ def unsuppress(customer_id) @client.request_and_verify_response(:post, unsuppress_path(customer_id)) end - def track(customer_id, event_name, attributes = {}) + def track(customer_id, event_name, attributes = {}, id: nil) raise ParamError, "customer_id must be a non-empty string" if empty?(customer_id) raise ParamError, "event_name must be a non-empty string" if empty?(event_name) - create_customer_event(customer_id, event_name, attributes) + create_customer_event(customer_id, event_name, attributes, id: id) end def pageview(customer_id, page, attributes = {}) @@ -66,10 +66,10 @@ def pageview(customer_id, page, attributes = {}) create_pageview_event(customer_id, page, attributes) end - def track_anonymous(anonymous_id, event_name, attributes = {}) + def track_anonymous(anonymous_id, event_name, attributes = {}, id: nil) raise ParamError, "event_name must be a non-empty string" if empty?(event_name) - create_anonymous_event(anonymous_id, event_name, attributes) + create_anonymous_event(anonymous_id, event_name, attributes, id: id) end def add_device(customer_id, device_id, platform, data = {}) @@ -190,20 +190,22 @@ def create_or_update(attributes = {}) @client.request_and_verify_response(:put, url, attributes) end - def create_customer_event(customer_id, event_name, attributes = {}) + def create_customer_event(customer_id, event_name, attributes = {}, id: nil) create_event( url: "#{customer_path(customer_id)}/events", event_name: event_name, - attributes: attributes + attributes: attributes, + id: id ) end - def create_anonymous_event(anonymous_id, event_name, attributes = {}) + def create_anonymous_event(anonymous_id, event_name, attributes = {}, id: nil) create_event( url: "/api/v1/events", event_name: event_name, anonymous_id: anonymous_id, - attributes: attributes + attributes: attributes, + id: id ) end @@ -216,11 +218,12 @@ def create_pageview_event(customer_id, page, attributes = {}) ) end - def create_event(url:, event_name:, anonymous_id: nil, event_type: nil, attributes: {}) + def create_event(url:, event_name:, anonymous_id: nil, event_type: nil, attributes: {}, id: nil) body = { name: event_name, data: attributes } body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp]) body[:anonymous_id] = anonymous_id unless empty?(anonymous_id) body[:type] = event_type unless empty?(event_type) + body[:id] = id unless empty?(id) @client.request_and_verify_response(:post, url, body) end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 27ba3db..a9854ec 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -466,6 +466,29 @@ def json(data) client.track(5, "purchase", type: "socks", price: "13.99", timestamp: "Hello world") end + it "sends an event id for deduplication when provided" do + stub_request(:post, api_uri('/api/v1/customers/5/events')). + with(body: json({ + name: "purchase", + data: { type: "socks" }, + id: "01HB4HBDKTFWYZCK01DMRSWRFD" + })). + to_return(status: 200, body: "", headers: {}) + + client.track(5, "purchase", { type: "socks" }, id: "01HB4HBDKTFWYZCK01DMRSWRFD") + end + + it "doesn't send id when not provided" do + stub_request(:post, api_uri('/api/v1/customers/5/events')). + with(body: json({ + name: "purchase", + data: { type: "socks" } + })). + to_return(status: 200, body: "", headers: {}) + + client.track(5, "purchase", type: "socks") + end + context "tracking an anonymous event" do let(:anon_id) { "anon-id" } @@ -540,6 +563,19 @@ def json(data) lambda { client.track_anonymous(anon_id, "") }.should raise_error(Customerio::Client::ParamError) end + + it "sends an event id for deduplication when provided" do + stub_request(:post, api_uri('/api/v1/events')). + with(body: { + anonymous_id: anon_id, + name: "purchase", + data: {}, + id: "01HB4HBDKTFWYZCK01DMRSWRFD" + }). + to_return(status: 200, body: "", headers: {}) + + client.track_anonymous(anon_id, "purchase", {}, id: "01HB4HBDKTFWYZCK01DMRSWRFD") + end end end From 8d45e946714e2a93e30e37547baf54a833171e5b Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 13:39:59 -0400 Subject: [PATCH 2/4] feat: add timestamp as explicit keyword argument to track methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow passing timestamp: as a keyword arg to track and track_anonymous, alongside the existing id: keyword. The explicit keyword takes precedence over a timestamp in the attributes hash. Backwards compatible — existing callers that pass timestamp in attributes continue to work. --- README.md | 8 ++++++++ lib/customerio/client.rb | 23 +++++++++++++---------- spec/client_spec.rb | 25 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index cff3da4..89880be 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,14 @@ $customerio.track(5, "purchase", { :type => "socks" }, id: "01BX5ZZKBKACTAV9WEVG $customerio.track_anonymous("anon-id", "purchase", { :type => "socks" }, id: "01BX5ZZKBKACTAV9WEVGEMMVRY") ``` +You can also pass `timestamp` as a keyword argument (epoch seconds) instead of including it in the attributes hash: + +```ruby +$customerio.track(5, "purchase", { :type => "socks" }, timestamp: 1561231234) + +$customerio.track(5, "purchase", { :type => "socks" }, id: "01BX5ZZKBKACTAV9WEVGEMMVRY", timestamp: 1561231234) +``` + ### Tracking anonymous events You can also send anonymous events, for situations where you don't yet have a customer record yet. An anonymous 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. diff --git a/lib/customerio/client.rb b/lib/customerio/client.rb index 50ae4cb..06c40be 100644 --- a/lib/customerio/client.rb +++ b/lib/customerio/client.rb @@ -52,11 +52,11 @@ def unsuppress(customer_id) @client.request_and_verify_response(:post, unsuppress_path(customer_id)) end - def track(customer_id, event_name, attributes = {}, id: nil) + def track(customer_id, event_name, attributes = {}, id: nil, timestamp: nil) raise ParamError, "customer_id must be a non-empty string" if empty?(customer_id) raise ParamError, "event_name must be a non-empty string" if empty?(event_name) - create_customer_event(customer_id, event_name, attributes, id: id) + create_customer_event(customer_id, event_name, attributes, id: id, timestamp: timestamp) end def pageview(customer_id, page, attributes = {}) @@ -66,10 +66,10 @@ def pageview(customer_id, page, attributes = {}) create_pageview_event(customer_id, page, attributes) end - def track_anonymous(anonymous_id, event_name, attributes = {}, id: nil) + def track_anonymous(anonymous_id, event_name, attributes = {}, id: nil, timestamp: nil) raise ParamError, "event_name must be a non-empty string" if empty?(event_name) - create_anonymous_event(anonymous_id, event_name, attributes, id: id) + create_anonymous_event(anonymous_id, event_name, attributes, id: id, timestamp: timestamp) end def add_device(customer_id, device_id, platform, data = {}) @@ -190,22 +190,24 @@ def create_or_update(attributes = {}) @client.request_and_verify_response(:put, url, attributes) end - def create_customer_event(customer_id, event_name, attributes = {}, id: nil) + def create_customer_event(customer_id, event_name, attributes = {}, id: nil, timestamp: nil) create_event( url: "#{customer_path(customer_id)}/events", event_name: event_name, attributes: attributes, - id: id + id: id, + timestamp: timestamp ) end - def create_anonymous_event(anonymous_id, event_name, attributes = {}, id: nil) + def create_anonymous_event(anonymous_id, event_name, attributes = {}, id: nil, timestamp: nil) create_event( url: "/api/v1/events", event_name: event_name, anonymous_id: anonymous_id, attributes: attributes, - id: id + id: id, + timestamp: timestamp ) end @@ -218,9 +220,10 @@ def create_pageview_event(customer_id, page, attributes = {}) ) end - def create_event(url:, event_name:, anonymous_id: nil, event_type: nil, attributes: {}, id: nil) + def create_event(url:, event_name:, anonymous_id: nil, event_type: nil, attributes: {}, id: nil, timestamp: nil) body = { name: event_name, data: attributes } - body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp]) + body[:timestamp] = timestamp if valid_timestamp?(timestamp) + body[:timestamp] = attributes[:timestamp] if body[:timestamp].nil? && valid_timestamp?(attributes[:timestamp]) body[:anonymous_id] = anonymous_id unless empty?(anonymous_id) body[:type] = event_type unless empty?(event_type) body[:id] = id unless empty?(id) diff --git a/spec/client_spec.rb b/spec/client_spec.rb index a9854ec..f3c318f 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -489,6 +489,31 @@ def json(data) client.track(5, "purchase", type: "socks") end + it "sends a timestamp as a top-level field when provided as keyword arg" do + stub_request(:post, api_uri('/api/v1/customers/5/events')). + with(body: json({ + name: "purchase", + data: { type: "socks" }, + timestamp: 1561231234 + })). + to_return(status: 200, body: "", headers: {}) + + client.track(5, "purchase", { type: "socks" }, timestamp: 1561231234) + end + + it "supports both id and timestamp keyword args together" do + stub_request(:post, api_uri('/api/v1/customers/5/events')). + with(body: json({ + name: "purchase", + data: { type: "socks" }, + id: "01HB4HBDKTFWYZCK01DMRSWRFD", + timestamp: 1561231234 + })). + to_return(status: 200, body: "", headers: {}) + + client.track(5, "purchase", { type: "socks" }, id: "01HB4HBDKTFWYZCK01DMRSWRFD", timestamp: 1561231234) + end + context "tracking an anonymous event" do let(:anon_id) { "anon-id" } From 91abff6e7dcd2d80e2d21a878810096de9b92a5a Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 13:48:01 -0400 Subject: [PATCH 3/4] Fix breaking change: require explicit hash braces for track attributes track() and track_anonymous() now accept id: and timestamp: keyword args. Callers must wrap attributes in explicit braces to avoid Ruby 3.x keyword argument ambiguity. Update all spec calls and README examples to use explicit hash syntax. --- README.md | 10 ++++----- spec/client_spec.rb | 51 +++++++++++++++++++++++++++------------------ 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 89880be..9818e75 100644 --- a/README.md +++ b/README.md @@ -189,14 +189,14 @@ encourage your customers to perform an action. # event. These attributes can be used in your triggers to control who should # receive the triggered email. You can set any number of data values. -$customerio.track(5, "purchase", :type => "socks", :price => "13.99") +$customerio.track(5, "purchase", { :type => "socks", :price => "13.99" }) ``` -**Note:** If you want to track events which occurred in the past, you can include a `timestamp` attribute +**Note:** If you want to track events which occurred in the past, you can pass a `timestamp` keyword argument (in seconds since the epoch), and we'll use that as the date the event occurred. ```ruby -$customerio.track(5, "purchase", :type => "socks", :price => "13.99", :timestamp => 1365436200) +$customerio.track(5, "purchase", { :type => "socks", :price => "13.99" }, timestamp: 1365436200) ``` #### Deduplicating events @@ -229,7 +229,7 @@ Anonymous events cannot trigger campaigns by themselves. To trigger a campaign, # name (required) - the name of the event you want to track. # attributes (optional) - related information you want to attach to the event. -$customerio.track_anonymous(anonymous_id, "product_view", :type => "socks" ) +$customerio.track_anonymous(anonymous_id, "product_view", { :type => "socks" }) ``` Use the `recipient` attribute to specify the email address to send the messages to. [See our documentation on how to use anonymous events for more details](https://customer.io/docs/invite-emails/). @@ -239,7 +239,7 @@ Use the `recipient` attribute to specify the email address to send the messages If you previously sent [invite events](https://customer.io/docs/anonymous-invite-emails/), you can achieve the same functionality by sending an anonymous event with `nil` for the anonymous identifier. To send anonymous invites, your event *must* include a `recipient` attribute. ```ruby -$customerio.track_anonymous(nil, "invite", :recipient => "new.person@example.com" ) +$customerio.track_anonymous(nil, "invite", { :recipient => "new.person@example.com" }) ``` ### Adding a mobile device diff --git a/spec/client_spec.rb b/spec/client_spec.rb index f3c318f..c80a635 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -360,7 +360,7 @@ def json(data) })). to_return(status: 200, body: "", headers: {}) - client.track(5, "purchase", type: "socks", price: "13.99") + client.track(5, "purchase", {type: "socks", price: "13.99"}) end it "copes with arrays" do @@ -373,7 +373,7 @@ def json(data) }). to_return(status: 200, body: "", headers: {}) - client.track(5, "event", things: ["a", "b", "c"]) + client.track(5, "event", {things: ["a", "b", "c"]}) end it "copes with hashes" do @@ -386,7 +386,7 @@ def json(data) }). to_return(status: 200, body: "", headers: {}) - client.track(5, "event", stuff: { a: "b" }) + client.track(5, "event", {stuff: { a: "b" }}) end it "sends a POST request as json using json headers" do @@ -402,7 +402,22 @@ def json(data) client.track(5, "purchase", data) end - it "allows sending of a timestamp" do + it "allows sending of a timestamp via keyword arg" do + stub_request(:post, api_uri('/api/v1/customers/5/events')). + with(body: json({ + name: "purchase", + data: { + type: "socks", + price: "13.99" + }, + timestamp: 1561231234 + })). + to_return(status: 200, body: "", headers: {}) + + client.track(5, "purchase", {type: "socks", price: "13.99"}, timestamp: 1561231234) + end + + it "allows sending of a timestamp via attributes for backwards compat" do stub_request(:post, api_uri('/api/v1/customers/5/events')). with(body: json({ name: "purchase", @@ -415,7 +430,7 @@ def json(data) })). to_return(status: 200, body: "", headers: {}) - client.track(5, "purchase", type: "socks", price: "13.99", timestamp: 1561231234) + client.track(5, "purchase", {type: "socks", price: "13.99", timestamp: 1561231234}) end it "doesn't send timestamp if timestamp is in milliseconds" do @@ -424,13 +439,12 @@ def json(data) name: "purchase", data: { type: "socks", - price: "13.99", - timestamp: 1561231234000 + price: "13.99" } })). to_return(status: 200, body: "", headers: {}) - client.track(5, "purchase", type: "socks", price: "13.99", timestamp: 1561231234000) + client.track(5, "purchase", {type: "socks", price: "13.99"}, timestamp: 1561231234000) end it "doesn't send timestamp if timestamp is a date" do @@ -441,13 +455,12 @@ def json(data) name: "purchase", data: { type: "socks", - price: "13.99", - timestamp: Time.now.to_s + price: "13.99" } }). to_return(status: 200, body: "", headers: {}) - client.track(5, "purchase", type: "socks", price: "13.99", timestamp: date) + client.track(5, "purchase", {type: "socks", price: "13.99"}, timestamp: date) end it "doesn't send timestamp if timestamp isn't an integer" do @@ -456,14 +469,13 @@ def json(data) name: "purchase", data: { type: "socks", - price: "13.99", - timestamp: "Hello world" + price: "13.99" } })). to_return(status: 200, body: "", headers: {}) - client.track(5, "purchase", type: "socks", price: "13.99", timestamp: "Hello world") + client.track(5, "purchase", {type: "socks", price: "13.99"}, timestamp: "Hello world") end it "sends an event id for deduplication when provided" do @@ -486,7 +498,7 @@ def json(data) })). to_return(status: 200, body: "", headers: {}) - client.track(5, "purchase", type: "socks") + client.track(5, "purchase", { type: "socks" }) end it "sends a timestamp as a top-level field when provided as keyword arg" do @@ -537,24 +549,23 @@ def json(data) }). to_return(status: 200, body: "", headers: {}) - client.track_anonymous(anon_id, "purchase", type: "socks", price: "13.99") + client.track_anonymous(anon_id, "purchase", { type: "socks", price: "13.99" }) end - it "allows sending of a timestamp" do + it "allows sending of a timestamp via keyword arg" do stub_request(:post, api_uri('/api/v1/events')). with(body: { anonymous_id: anon_id, name: "purchase", data: { type: "socks", - price: "13.99", - timestamp: 1561231234 + price: "13.99" }, timestamp: 1561231234 }). to_return(status: 200, body: "", headers: {}) - client.track_anonymous(anon_id, "purchase", type: "socks", price: "13.99", timestamp: 1561231234) + client.track_anonymous(anon_id, "purchase", { type: "socks", price: "13.99" }, timestamp: 1561231234) end it "raises an error if POST doesn't return a 2xx response code" do From a932ee391f3b71b8e5ccae55cec01196bdfab7ca Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 13:58:00 -0400 Subject: [PATCH 4/4] Fix CI: swap key order in test stub, disable ParameterLists cop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebMock compares serialized JSON strings, so key order must match the insertion order in create_event (timestamp before id). Disable Metrics/ParameterLists on create_event — private method with keyword args, splitting would add complexity for no benefit. --- lib/customerio/client.rb | 2 +- spec/client_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/customerio/client.rb b/lib/customerio/client.rb index 06c40be..98e27da 100644 --- a/lib/customerio/client.rb +++ b/lib/customerio/client.rb @@ -220,7 +220,7 @@ def create_pageview_event(customer_id, page, attributes = {}) ) end - def create_event(url:, event_name:, anonymous_id: nil, event_type: nil, attributes: {}, id: nil, timestamp: nil) + def create_event(url:, event_name:, anonymous_id: nil, event_type: nil, attributes: {}, id: nil, timestamp: nil) # rubocop:disable Metrics/ParameterLists body = { name: event_name, data: attributes } body[:timestamp] = timestamp if valid_timestamp?(timestamp) body[:timestamp] = attributes[:timestamp] if body[:timestamp].nil? && valid_timestamp?(attributes[:timestamp]) diff --git a/spec/client_spec.rb b/spec/client_spec.rb index c80a635..bcaa54c 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -518,8 +518,8 @@ def json(data) with(body: json({ name: "purchase", data: { type: "socks" }, - id: "01HB4HBDKTFWYZCK01DMRSWRFD", - timestamp: 1561231234 + timestamp: 1561231234, + id: "01HB4HBDKTFWYZCK01DMRSWRFD" })). to_return(status: 200, body: "", headers: {})