From adf538d05c44ba4a23a406fcb4e982edccd60b78 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 17:26:18 -0400 Subject: [PATCH 1/4] Add track_delivery_metric for /api/v1/metrics endpoint Adds support for reporting delivery metrics (opened, clicked, converted, delivered, bounced, deferred, dropped, spammed) via the new /api/v1/metrics endpoint, replacing the deprecated /push/events endpoint. Based on community PR #112 by @eserra with fixes for: - valid_url? bug that passed boolean instead of URL string - .present? usage (Rails-only, unavailable in plain Ruby) - Breaking constant renames (PUSH_* constants preserved) - Inconsistent attribute handling (now uses symbolize_keys/slice) --- .rubocop.yml | 2 +- CHANGELOG.markdown | 5 ++ README.md | 20 +++++ lib/customerio/client.rb | 38 +++++++++ spec/client_spec.rb | 164 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index dc0362d..2228d1c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -20,7 +20,7 @@ Metrics/BlockLength: - "customerio.gemspec" Metrics/ClassLength: - Max: 200 + Max: 275 Metrics/MethodLength: Max: 25 diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown index 383f401..1e5dfbb 100644 --- a/CHANGELOG.markdown +++ b/CHANGELOG.markdown @@ -1,3 +1,8 @@ +## Customerio 5.7.0 - Unreleased +### Added +- Added `track_delivery_metric` to `Client` for reporting delivery metrics via the `/api/v1/metrics` endpoint, replacing the deprecated `/push/events` endpoint. Supports metrics: opened, clicked, converted, delivered, bounced, deferred, dropped, and spammed. +- Added `DELIVERY_*` constants and `VALID_DELIVERY_METRICS` to `Customerio::Client`. + ## Customerio 5.4.0 - June 13, 2025 ### Changed - Added `send_sms` to `APIClient` and `SendSMSRequest` to support sending transactional push notifications. diff --git a/README.md b/README.md index 0ac5d94..8a32843 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,26 @@ Deleting a device token will remove it from the associated customer to stop furt $customerio.delete_device(5, "my_device_token") ``` +### Tracking delivery metrics + +Report delivery metrics using the `track_delivery_metric` method, which calls the `/api/v1/metrics` endpoint. This replaces the deprecated `/push/events` endpoint. The `delivery_id` is required. Valid metrics are: `opened`, `clicked`, `converted`, `delivered`, `bounced`, `deferred`, `dropped`, `spammed`. + +```ruby +$customerio.track_delivery_metric("opened", delivery_id: "RPILAgUBcRillFPDbQQ=") + +$customerio.track_delivery_metric("clicked", { + delivery_id: "RPILAgUBcRillFPDbQQ=", + timestamp: 1561231234, + href: "https://example.com/link", + recipient: "user@example.com" +}) + +$customerio.track_delivery_metric("bounced", { + delivery_id: "RPILAgUBcRillFPDbQQ=", + reason: "mailbox full" +}) +``` + ### Suppress a user Deletes the customer with the provided id if it exists and suppresses all future events and identifies for that customer. diff --git a/lib/customerio/client.rb b/lib/customerio/client.rb index 078adc5..685b1ed 100644 --- a/lib/customerio/client.rb +++ b/lib/customerio/client.rb @@ -16,6 +16,21 @@ class Client VALID_PUSH_EVENTS = [PUSH_OPENED, PUSH_CONVERTED, PUSH_DELIVERED].freeze + DELIVERY_OPENED = "opened" + DELIVERY_CLICKED = "clicked" + DELIVERY_CONVERTED = "converted" + DELIVERY_DELIVERED = "delivered" + DELIVERY_BOUNCED = "bounced" + DELIVERY_DEFERRED = "deferred" + DELIVERY_DROPPED = "dropped" + DELIVERY_SPAMMED = "spammed" + + VALID_DELIVERY_METRICS = [ + DELIVERY_OPENED, DELIVERY_CLICKED, DELIVERY_CONVERTED, + DELIVERY_DELIVERED, DELIVERY_BOUNCED, DELIVERY_DEFERRED, + DELIVERY_DROPPED, DELIVERY_SPAMMED + ].freeze + class MissingIdAttributeError < StandardError; end class ParamError < StandardError; end @@ -114,6 +129,25 @@ def track_push_notification_event(event_name, attributes = {}) ) end + def track_delivery_metric(metric_name, attributes = {}) + keys = %i[delivery_id timestamp recipient reason href] + attributes = symbolize_keys(attributes).slice(*keys) + + unless VALID_DELIVERY_METRICS.include?(metric_name) + raise ParamError, "metric_name must be one of: #{VALID_DELIVERY_METRICS.join(", ")}" + end + + raise ParamError, "delivery_id must be a non-empty string" if empty?(attributes[:delivery_id]) + + body = { delivery_id: attributes[:delivery_id], metric: metric_name } + body[:timestamp] = attributes[:timestamp] if valid_timestamp?(attributes[:timestamp]) + body[:recipient] = attributes[:recipient] unless empty?(attributes[:recipient]) + body[:reason] = attributes[:reason] unless empty?(attributes[:reason]) + body[:href] = attributes[:href] unless empty?(attributes[:href]) + + @client.request_and_verify_response(:post, delivery_metrics_path, body) + end + def merge_customers(primary_id_type, primary_id, secondary_id_type, secondary_id) raise ParamError, "invalid primary_id_type" unless valid_id_type?(primary_id_type) raise ParamError, "primary_id must be a non-empty string" if empty?(primary_id) @@ -156,6 +190,10 @@ def track_push_notification_event_path "/push/events" end + def delivery_metrics_path + "/api/v1/metrics" + end + def merge_customers_path "/api/v1/merge_customers" end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 27ba3db..3eb5ddc 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -685,6 +685,170 @@ def json(data) end end + describe "#track_delivery_metric" do + attr_accessor :client, :attributes + + before(:each) do + @client = Customerio::Client.new("SITE_ID", "API_KEY", :json => true) + @attributes = { + :delivery_id => "abc123" + } + end + + it "sends a POST request to customer.io's /api/v1/metrics endpoint" do + stub_request(:post, api_uri("/api/v1/metrics")). + with( + :body => json({ + :delivery_id => "abc123", + :metric => "opened" + }), + :headers => { + "Content-Type" => "application/json" + }). + to_return(:status => 200, :body => "", :headers => {}) + + client.track_delivery_metric("opened", attributes) + end + + it "sends optional attributes when provided" do + time = Time.now.to_i + + stub_request(:post, api_uri("/api/v1/metrics")). + with( + :body => json({ + :delivery_id => "abc123", + :metric => "clicked", + :timestamp => time, + :recipient => "user@example.com", + :href => "https://example.com/page" + }), + :headers => { + "Content-Type" => "application/json" + }). + to_return(:status => 200, :body => "", :headers => {}) + + client.track_delivery_metric("clicked", { + :delivery_id => "abc123", + :timestamp => time, + :recipient => "user@example.com", + :href => "https://example.com/page" + }) + end + + it "sends reason attribute for bounced metrics" do + stub_request(:post, api_uri("/api/v1/metrics")). + with( + :body => json({ + :delivery_id => "abc123", + :metric => "bounced", + :reason => "mailbox full" + }), + :headers => { + "Content-Type" => "application/json" + }). + to_return(:status => 200, :body => "", :headers => {}) + + client.track_delivery_metric("bounced", { + :delivery_id => "abc123", + :reason => "mailbox full" + }) + end + + it "ignores attributes not in the allowed list" do + stub_request(:post, api_uri("/api/v1/metrics")). + with( + :body => json({ + :delivery_id => "abc123", + :metric => "opened" + }), + :headers => { + "Content-Type" => "application/json" + }). + to_return(:status => 200, :body => "", :headers => {}) + + client.track_delivery_metric("opened", { + :delivery_id => "abc123", + :device_id => "should_be_ignored", + :extra => "also_ignored" + }) + end + + it "omits timestamp when not a valid integer" do + stub_request(:post, api_uri("/api/v1/metrics")). + with( + :body => json({ + :delivery_id => "abc123", + :metric => "delivered" + }), + :headers => { + "Content-Type" => "application/json" + }). + to_return(:status => 200, :body => "", :headers => {}) + + client.track_delivery_metric("delivered", { + :delivery_id => "abc123", + :timestamp => "not-a-timestamp" + }) + end + + it "should raise if metric_name is invalid" do + expect { + client.track_delivery_metric("closed", attributes) + }.to raise_error(Customerio::Client::ParamError, /metric_name must be one of/) + end + + it "should raise if delivery_id is missing" do + expect { + client.track_delivery_metric("opened", { :delivery_id => nil }) + }.to raise_error(Customerio::Client::ParamError, "delivery_id must be a non-empty string") + + expect { + client.track_delivery_metric("opened", { :delivery_id => "" }) + }.to raise_error(Customerio::Client::ParamError, "delivery_id must be a non-empty string") + end + + it "should raise if delivery_id is whitespace" do + expect { + client.track_delivery_metric("opened", { :delivery_id => " " }) + }.to raise_error(Customerio::Client::ParamError, "delivery_id must be a non-empty string") + end + + %w[opened clicked converted delivered bounced deferred dropped spammed].each do |metric| + it "accepts '#{metric}' as a valid metric" do + stub_request(:post, api_uri("/api/v1/metrics")). + to_return(:status => 200, :body => "", :headers => {}) + + client.track_delivery_metric(metric, attributes) + end + end + + it "works with string keys in the attributes hash" do + stub_request(:post, api_uri("/api/v1/metrics")). + with( + :body => json({ + :delivery_id => "abc123", + :metric => "opened" + }), + :headers => { + "Content-Type" => "application/json" + }). + to_return(:status => 200, :body => "", :headers => {}) + + client.track_delivery_metric("opened", { + "delivery_id" => "abc123" + }) + end + + it "raises an error if POST doesn't return a 2xx response code" do + stub_request(:post, api_uri("/api/v1/metrics")). + to_return(:status => 500, :body => "Server Error", :headers => {}) + + expect { + client.track_delivery_metric("opened", attributes) + }.to raise_error(Customerio::InvalidResponse) + end + end + describe "#merge_customers" do before(:each) do @client = Customerio::Client.new("SITE_ID", "API_KEY", :json => true) From 2fd7095fd58594ab0b1be42cbe9221611173ca88 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 17:28:16 -0400 Subject: [PATCH 2/4] Remove PUSH_* constants, use DELIVERY_* for VALID_PUSH_EVENTS PUSH_OPENED/PUSH_CONVERTED/PUSH_DELIVERED were duplicates of DELIVERY_OPENED/DELIVERY_CONVERTED/DELIVERY_DELIVERED. Consolidate to a single set of constants. --- lib/customerio/client.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/customerio/client.rb b/lib/customerio/client.rb index 685b1ed..664c602 100644 --- a/lib/customerio/client.rb +++ b/lib/customerio/client.rb @@ -10,12 +10,6 @@ class IdentifierType end class Client - PUSH_OPENED = "opened" - PUSH_CONVERTED = "converted" - PUSH_DELIVERED = "delivered" - - VALID_PUSH_EVENTS = [PUSH_OPENED, PUSH_CONVERTED, PUSH_DELIVERED].freeze - DELIVERY_OPENED = "opened" DELIVERY_CLICKED = "clicked" DELIVERY_CONVERTED = "converted" @@ -31,6 +25,8 @@ class Client DELIVERY_DROPPED, DELIVERY_SPAMMED ].freeze + VALID_PUSH_EVENTS = [DELIVERY_OPENED, DELIVERY_CONVERTED, DELIVERY_DELIVERED].freeze + class MissingIdAttributeError < StandardError; end class ParamError < StandardError; end From 17137aac76041fa5c407525b7267c73eb1d11031 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 20:35:51 -0400 Subject: [PATCH 3/4] Fix rubocop: use single quotes inside string interpolation --- lib/customerio/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/customerio/client.rb b/lib/customerio/client.rb index 664c602..35f3fcb 100644 --- a/lib/customerio/client.rb +++ b/lib/customerio/client.rb @@ -130,7 +130,7 @@ def track_delivery_metric(metric_name, attributes = {}) attributes = symbolize_keys(attributes).slice(*keys) unless VALID_DELIVERY_METRICS.include?(metric_name) - raise ParamError, "metric_name must be one of: #{VALID_DELIVERY_METRICS.join(", ")}" + raise ParamError, "metric_name must be one of: #{VALID_DELIVERY_METRICS.join(', ')}" end raise ParamError, "delivery_id must be a non-empty string" if empty?(attributes[:delivery_id]) From a1aecda642b44114b4d341e6e910e4aed502ccc7 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 7 May 2026 20:36:16 -0400 Subject: [PATCH 4/4] Add back PUSH_* constants as deprecated aliases Preserves backwards compatibility for anyone referencing PUSH_OPENED, PUSH_CONVERTED, or PUSH_DELIVERED directly. --- lib/customerio/client.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/customerio/client.rb b/lib/customerio/client.rb index 35f3fcb..a0cfed6 100644 --- a/lib/customerio/client.rb +++ b/lib/customerio/client.rb @@ -27,6 +27,11 @@ class Client VALID_PUSH_EVENTS = [DELIVERY_OPENED, DELIVERY_CONVERTED, DELIVERY_DELIVERED].freeze + # @deprecated Use DELIVERY_OPENED, DELIVERY_CONVERTED, DELIVERY_DELIVERED instead. + PUSH_OPENED = DELIVERY_OPENED + PUSH_CONVERTED = DELIVERY_CONVERTED + PUSH_DELIVERED = DELIVERY_DELIVERED + class MissingIdAttributeError < StandardError; end class ParamError < StandardError; end