diff --git a/README.md b/README.md index 8c57e59..9818e75 100644 --- a/README.md +++ b/README.md @@ -189,14 +189,32 @@ 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 + +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") +``` + +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 @@ -211,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/). @@ -221,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/lib/customerio/client.rb b/lib/customerio/client.rb index 078adc5..98e27da 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, 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) + 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 = {}) + 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) + create_anonymous_event(anonymous_id, event_name, attributes, id: id, timestamp: timestamp) end def add_device(customer_id, device_id, platform, data = {}) @@ -190,20 +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 = {}) + 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 + attributes: attributes, + id: id, + timestamp: timestamp ) end - def create_anonymous_event(anonymous_id, event_name, attributes = {}) + 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 + attributes: attributes, + id: id, + timestamp: timestamp ) end @@ -216,11 +220,13 @@ 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, timestamp: nil) # rubocop:disable Metrics/ParameterLists 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) @client.request_and_verify_response(:post, url, body) end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 27ba3db..bcaa54c 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,61 @@ 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 + 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 + + 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" }, + timestamp: 1561231234, + id: "01HB4HBDKTFWYZCK01DMRSWRFD" + })). + to_return(status: 200, body: "", headers: {}) + + client.track(5, "purchase", { type: "socks" }, id: "01HB4HBDKTFWYZCK01DMRSWRFD", timestamp: 1561231234) end context "tracking an anonymous event" do @@ -489,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 @@ -540,6 +599,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