diff --git a/README.md b/README.md index 0341418..3969de1 100644 --- a/README.md +++ b/README.md @@ -47,38 +47,67 @@ Altertable.init('pk_live_abc123', environment: 'production', debug: true) Record an action performed by a user. -`Altertable.track(event, distinct_id, **options)` +`Altertable.track(event, distinct_id, **options)` +`Altertable.track(event_payload, **options)` +`Altertable.track(event_payloads, **options)` ```ruby Altertable.track('item_purchased', 'user_123', properties: { item_id: 'item_999', price: 19.99 }) + +Altertable.track({ + event: 'item_purchased', + distinct_id: 'user_123', + properties: { item_id: 'item_999', price: 19.99 } +}) + +Altertable.track([ + { event: 'signup', distinct_id: 'user_1', properties: { plan: 'pro' } }, + { event: 'login', distinct_id: 'user_2', timestamp: '2025-06-15T14:30:00.000Z' } +]) ``` ### Identifying Users Link a user ID to their traits (like email or name). -`Altertable.identify(user_id, **options)` +`Altertable.identify(user_id, **options)` +`Altertable.identify(identify_payload, **options)` +`Altertable.identify(identify_payloads, **options)` ```ruby Altertable.identify('user_123', traits: { email: 'user@example.com', name: 'John Doe' }) + +Altertable.identify([ + { user_id: 'user_1', traits: { email: 'one@example.com' } }, + { user_id: 'user_2', traits: { email: 'two@example.com' } } +]) ``` ### Alias Merge a previous anonymous ID with a newly identified user ID. -`Altertable.alias(distinct_id, new_user_id, **options)` +`Altertable.alias(distinct_id, new_user_id, **options)` +`Altertable.alias(alias_payload, **options)` +`Altertable.alias(alias_payloads, **options)` ```ruby Altertable.alias('anon_session_456', 'user_123') + +Altertable.alias([ + { distinct_id: 'anon_1', new_user_id: 'user_1' }, + { distinct_id: 'anon_2', new_user_id: 'user_2' } +]) ``` +A Hash payload posts one object; an Array posts a JSON array on the same endpoint. Omitted `timestamp` values default to the current time as ISO 8601. + ## Configuration You can configure the client by passing options during initialization. diff --git a/lib/altertable.rb b/lib/altertable.rb index 788b778..d15a867 100644 --- a/lib/altertable.rb +++ b/lib/altertable.rb @@ -10,7 +10,7 @@ def init(api_key, options = {}) @client = Client.new(api_key, options) end - def track(event, distinct_id, **options) + def track(event, distinct_id = nil, **options) client.track(event, distinct_id, **options) end @@ -18,7 +18,7 @@ def identify(user_id, **options) client.identify(user_id, **options) end - def alias(distinct_id, new_user_id, **options) + def alias(distinct_id, new_user_id = nil, **options) client.alias(distinct_id, new_user_id, **options) end diff --git a/lib/altertable/client.rb b/lib/altertable/client.rb index 7ff285d..007e1a8 100644 --- a/lib/altertable/client.rb +++ b/lib/altertable/client.rb @@ -31,51 +31,40 @@ def initialize(api_key, options = {}) @adapter = select_adapter(adapter_name, { base_url: @base_url, timeout: @timeout, headers: headers, proxy: options[:proxy] }) end - def track(event, distinct_id, **options) - properties = options[:properties] || {} - timestamp = options[:timestamp] || Time.now.utc.iso8601(3) - payload = { - timestamp: timestamp, - event: event, - environment: @environment, - distinct_id: distinct_id, - properties: { - '$lib': "altertable-ruby", - '$lib_version': Altertable::VERSION - }.merge(properties) - } - payload[:properties]["$release"] = @release if @release - payload[:anonymous_id] = options[:anonymous_id] if options.key?(:anonymous_id) - payload[:device_id] = options[:device_id] if options.key?(:device_id) - - post("/track", payload) + def track(event, distinct_id = nil, **options) + case event + when Array + payloads = map_batch(event, "events") { |item| track_payload_from_item(merge_payload(item, options)) } + post("/track", payloads) + when Hash + post("/track", track_payload_from_item(merge_payload(event, options))) + else + post("/track", track_payload(event, distinct_id, options)) + end end def identify(user_id, **options) - traits = options[:traits] || {} - timestamp = options[:timestamp] || Time.now.utc.iso8601(3) - payload = { - timestamp: timestamp, - environment: @environment, - distinct_id: user_id, - traits: traits - } - payload[:anonymous_id] = options[:anonymous_id] if options.key?(:anonymous_id) - payload[:device_id] = options[:device_id] if options.key?(:device_id) - - post("/identify", payload) + case user_id + when Array + payloads = map_batch(user_id, "identifies") { |item| identify_payload_from_item(merge_payload(item, options)) } + post("/identify", payloads) + when Hash + post("/identify", identify_payload_from_item(merge_payload(user_id, options))) + else + post("/identify", identify_payload(user_id, options)) + end end - def alias(distinct_id, new_user_id, **options) - timestamp = options[:timestamp] || Time.now.utc.iso8601(3) - payload = { - timestamp: timestamp, - environment: @environment, - distinct_id: distinct_id, - new_user_id: new_user_id - } - - post("/alias", payload) + def alias(distinct_id, new_user_id = nil, **options) + case distinct_id + when Array + payloads = map_batch(distinct_id, "aliases") { |item| alias_payload_from_item(merge_payload(item, options)) } + post("/alias", payloads) + when Hash + post("/alias", alias_payload_from_item(merge_payload(distinct_id, options))) + else + post("/alias", alias_payload(distinct_id, new_user_id, options)) + end end private @@ -107,6 +96,108 @@ def try_require(gem_name) false end + def map_batch(items, name, &block) + raise ArgumentError, "#{name} must be a non-empty Array" unless items.is_a?(Array) && !items.empty? + + items.each_with_index do |item, index| + raise ArgumentError, "#{name}[#{index}] must be a Hash" unless item.is_a?(Hash) + end + + items.map(&block) + end + + def merge_payload(item, options) + return item if options.empty? + + item.merge(options) + end + + def item_value(item, key) + if item.key?(key) + item[key] + elsif item.key?(key.to_s) + item[key.to_s] + end + end + + def item_options(item, *keys) + keys.each_with_object({}) do |key, opts| + opts[key] = item_value(item, key) if item.key?(key) || item.key?(key.to_s) + end + end + + def blank?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + + def default_timestamp + Time.now.utc.iso8601(3) + end + + def track_payload_from_item(item) + event = item_value(item, :event) + distinct_id = item_value(item, :distinct_id) + raise ArgumentError, "event is required" if blank?(event) + raise ArgumentError, "distinct_id is required" if blank?(distinct_id) + + track_payload(event, distinct_id, item_options(item, :properties, :anonymous_id, :device_id, :timestamp)) + end + + def identify_payload_from_item(item) + user_id = item_value(item, :user_id) + raise ArgumentError, "user_id is required" if blank?(user_id) + + identify_payload(user_id, item_options(item, :traits, :anonymous_id, :device_id, :timestamp)) + end + + def alias_payload_from_item(item) + distinct_id = item_value(item, :distinct_id) + new_user_id = item_value(item, :new_user_id) + raise ArgumentError, "distinct_id is required" if blank?(distinct_id) + raise ArgumentError, "new_user_id is required" if blank?(new_user_id) + + alias_payload(distinct_id, new_user_id, item_options(item, :timestamp)) + end + + def track_payload(event, distinct_id, options) + properties = options[:properties] || {} + payload = { + timestamp: options[:timestamp] || default_timestamp, + event: event, + environment: @environment, + distinct_id: distinct_id, + properties: { + '$lib': "altertable-ruby", + '$lib_version': Altertable::VERSION + }.merge(properties) + } + payload[:properties]["$release"] = @release if @release + payload[:anonymous_id] = options[:anonymous_id] if options.key?(:anonymous_id) + payload[:device_id] = options[:device_id] if options.key?(:device_id) + payload + end + + def identify_payload(user_id, options) + payload = { + timestamp: options[:timestamp] || default_timestamp, + environment: @environment, + distinct_id: user_id, + traits: options[:traits] || {} + } + payload[:anonymous_id] = options[:anonymous_id] if options.key?(:anonymous_id) + payload[:device_id] = options[:device_id] if options.key?(:device_id) + payload + end + + def alias_payload(distinct_id, new_user_id, options) + { + timestamp: options[:timestamp] || default_timestamp, + environment: @environment, + distinct_id: distinct_id, + new_user_id: new_user_id + } + end + def post(path, payload) res = @adapter.post(path, body: payload.to_json) handle_response(res) diff --git a/rbi/altertable.rbi b/rbi/altertable.rbi index 98a139f..88962c1 100644 --- a/rbi/altertable.rbi +++ b/rbi/altertable.rbi @@ -34,14 +34,14 @@ module Altertable sig { params(api_key: String, options: T::Hash[Symbol, T.untyped]).void } def initialize(api_key, options = {}); end - sig { params(event: String, distinct_id: String, options: T.untyped).returns(T.untyped) } - def track(event, distinct_id, **options); end + sig { params(event: T.any(String, T::Hash[T.any(Symbol, String), T.untyped], T::Array[T::Hash[T.any(Symbol, String), T.untyped]]), distinct_id: T.nilable(String), options: T.untyped).returns(T.untyped) } + def track(event, distinct_id = nil, **options); end - sig { params(user_id: String, options: T.untyped).returns(T.untyped) } + sig { params(user_id: T.any(String, T::Hash[T.any(Symbol, String), T.untyped], T::Array[T::Hash[T.any(Symbol, String), T.untyped]]), options: T.untyped).returns(T.untyped) } def identify(user_id, **options); end - sig { params(distinct_id: String, new_user_id: String, options: T.untyped).returns(T.untyped) } - def alias(distinct_id, new_user_id, **options); end + sig { params(distinct_id: T.any(String, T::Hash[T.any(Symbol, String), T.untyped], T::Array[T::Hash[T.any(Symbol, String), T.untyped]]), new_user_id: T.nilable(String), options: T.untyped).returns(T.untyped) } + def alias(distinct_id, new_user_id = nil, **options); end private @@ -51,7 +51,7 @@ module Altertable sig { params(gem_name: String).returns(T::Boolean) } def try_require(gem_name); end - sig { params(path: String, payload: T::Hash[T.any(Symbol, String), T.untyped]).returns(T.untyped) } + sig { params(path: String, payload: T.any(T::Hash[T.any(Symbol, String), T.untyped], T::Array[T::Hash[T.any(Symbol, String), T.untyped]])).returns(T.untyped) } def post(path, payload); end sig { params(res: T.untyped).returns(T.untyped) } @@ -124,14 +124,14 @@ module Altertable sig { params(api_key: String, options: T::Hash[Symbol, T.untyped]).returns(Client) } def self.init(api_key, options = {}); end - sig { params(event: String, distinct_id: String, options: T.untyped).returns(T.untyped) } - def self.track(event, distinct_id, **options); end + sig { params(event: T.any(String, T::Hash[T.any(Symbol, String), T.untyped], T::Array[T::Hash[T.any(Symbol, String), T.untyped]]), distinct_id: T.nilable(String), options: T.untyped).returns(T.untyped) } + def self.track(event, distinct_id = nil, **options); end - sig { params(user_id: String, options: T.untyped).returns(T.untyped) } + sig { params(user_id: T.any(String, T::Hash[T.any(Symbol, String), T.untyped], T::Array[T::Hash[T.any(Symbol, String), T.untyped]]), options: T.untyped).returns(T.untyped) } def self.identify(user_id, **options); end - sig { params(distinct_id: String, new_user_id: String, options: T.untyped).returns(T.untyped) } - def self.alias(distinct_id, new_user_id, **options); end + sig { params(distinct_id: T.any(String, T::Hash[T.any(Symbol, String), T.untyped], T::Array[T::Hash[T.any(Symbol, String), T.untyped]]), new_user_id: T.nilable(String), options: T.untyped).returns(T.untyped) } + def self.alias(distinct_id, new_user_id = nil, **options); end sig { returns(Client) } def self.client; end diff --git a/sig/altertable.rbs b/sig/altertable.rbs index 6954ef2..821b70e 100644 --- a/sig/altertable.rbs +++ b/sig/altertable.rbs @@ -4,10 +4,16 @@ module Altertable def self.init: (String api_key, ?::Hash[Symbol, untyped] options) -> Client def self.track: (String event, String distinct_id, **untyped options) -> untyped + | (::Hash[Symbol | String, untyped] event_payload, **untyped options) -> untyped + | (::Array[::Hash[Symbol | String, untyped]] event_payloads, **untyped options) -> untyped def self.identify: (String user_id, **untyped options) -> untyped + | (::Hash[Symbol | String, untyped] identify_payload, **untyped options) -> untyped + | (::Array[::Hash[Symbol | String, untyped]] identify_payloads, **untyped options) -> untyped def self.alias: (String distinct_id, String new_user_id, **untyped options) -> untyped + | (::Hash[Symbol | String, untyped] alias_payload, **untyped options) -> untyped + | (::Array[::Hash[Symbol | String, untyped]] alias_payloads, **untyped options) -> untyped def self.client: () -> Client end diff --git a/sig/altertable/client.rbs b/sig/altertable/client.rbs index 9393e8e..b511464 100644 --- a/sig/altertable/client.rbs +++ b/sig/altertable/client.rbs @@ -16,16 +16,22 @@ module Altertable def initialize: (String api_key, ?::Hash[Symbol, untyped] options) -> void def track: (String event, String distinct_id, **untyped options) -> untyped + | (::Hash[Symbol | String, untyped] event_payload, **untyped options) -> untyped + | (::Array[::Hash[Symbol | String, untyped]] event_payloads, **untyped options) -> untyped def identify: (String user_id, **untyped options) -> untyped + | (::Hash[Symbol | String, untyped] identify_payload, **untyped options) -> untyped + | (::Array[::Hash[Symbol | String, untyped]] identify_payloads, **untyped options) -> untyped def alias: (String distinct_id, String new_user_id, **untyped options) -> untyped + | (::Hash[Symbol | String, untyped] alias_payload, **untyped options) -> untyped + | (::Array[::Hash[Symbol | String, untyped]] alias_payloads, **untyped options) -> untyped private def select_adapter: (Symbol? name, ::Hash[Symbol, untyped] options) -> untyped def try_require: (String gem_name) -> bool - def post: (String path, ::Hash[Symbol | String, untyped] payload) -> untyped + def post: (String path, ::Hash[Symbol | String, untyped] | ::Array[::Hash[Symbol | String, untyped]] payload) -> untyped def handle_response: (untyped res) -> untyped def handle_error: (Exception error) -> untyped end diff --git a/spec/altertable/client_batch_spec.rb b/spec/altertable/client_batch_spec.rb new file mode 100644 index 0000000..44311b8 --- /dev/null +++ b/spec/altertable/client_batch_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require "spec_helper" +require "json" + +RSpec.describe Altertable::Client do + let(:api_key) { "test_pk_abc123" } + let(:adapter) { RecordingAdapter.new } + let(:client) do + described_class.new(api_key, environment: "production", release: "1.2.3").tap do |c| + c.instance_variable_set(:@adapter, adapter) + end + end + + class RecordingAdapter + attr_reader :calls + + def initialize(responses: nil) + @calls = [] + @responses = responses + end + + def post(path, body: nil, params: {}) + @calls << { path: path, body: JSON.parse(body), params: params } + response = if @responses + @responses.shift || { "ok" => true } + else + { "ok" => true, "task_id" => "task-1" } + end + Altertable::Adapters::Response.new(200, JSON.generate(response), {}) + end + end + + describe "#track" do + it "posts a single event from positional arguments" do + client.track("signup", "u1", properties: { plan: "pro" }, timestamp: "2025-06-15T14:30:00.000Z") + + expect(adapter.calls.size).to eq(1) + expect(adapter.calls.first[:path]).to eq("/track") + payload = adapter.calls.first[:body] + expect(payload).to include( + "event" => "signup", + "distinct_id" => "u1", + "environment" => "production", + "timestamp" => "2025-06-15T14:30:00.000Z" + ) + expect(payload["properties"]).to include("plan" => "pro", "$lib" => "altertable-ruby") + end + + it "posts a single event from a payload hash" do + client.track({ + event: "signup", + distinct_id: "u1", + properties: { plan: "pro" }, + timestamp: "2025-06-15T14:30:00.000Z" + }) + + payload = adapter.calls.first[:body] + expect(payload).to be_a(Hash) + expect(payload).to include("event" => "signup", "distinct_id" => "u1") + expect(payload["properties"]).to include("plan" => "pro", "$release" => "1.2.3") + end + + it "posts an array of track payloads in one request" do + client.track([ + { event: "signup", distinct_id: "u1", properties: { plan: "pro" }, timestamp: "2025-06-15T14:30:00.000Z" }, + { event: "login", distinct_id: "u2", timestamp: "2025-06-15T14:31:00.000Z", anonymous_id: "anon-1" } + ]) + + expect(adapter.calls.size).to eq(1) + expect(adapter.calls.first[:path]).to eq("/track") + payloads = adapter.calls.first[:body] + expect(payloads).to be_an(Array) + expect(payloads.size).to eq(2) + expect(payloads[0]).to include( + "event" => "signup", + "distinct_id" => "u1", + "environment" => "production", + "timestamp" => "2025-06-15T14:30:00.000Z" + ) + expect(payloads[0]["properties"]).to include("plan" => "pro", "$lib" => "altertable-ruby", "$lib_version" => Altertable::VERSION, "$release" => "1.2.3") + expect(payloads[1]).to include( + "event" => "login", + "distinct_id" => "u2", + "anonymous_id" => "anon-1", + "timestamp" => "2025-06-15T14:31:00.000Z" + ) + end + + it "defaults omitted timestamps to ISO 8601" do + client.track([{ event: "signup", distinct_id: "u1" }]) + + timestamp = adapter.calls.first[:body].first["timestamp"] + expect { Time.iso8601(timestamp) }.not_to raise_error + end + + it "returns the parsed response" do + response = client.track([{ event: "signup", distinct_id: "u1" }]) + expect(response).to include("ok" => true) + end + + it "raises ArgumentError for an empty list" do + expect { client.track([]) }.to raise_error(ArgumentError, /empty/) + expect(adapter.calls).to be_empty + end + + it "raises ArgumentError when event is missing from a payload" do + expect { client.track([{ distinct_id: "u1" }]) }.to raise_error(ArgumentError, /event/) + end + + it "raises ArgumentError when a batch item is not a Hash" do + expect { client.track([nil]) }.to raise_error(ArgumentError, "events[0] must be a Hash") + expect { client.track([{ event: "signup", distinct_id: "u1" }, "nope"]) }.to raise_error(ArgumentError, "events[1] must be a Hash") + expect(adapter.calls).to be_empty + end + + it "accepts string-key hashes in a batch" do + client.track([ + { + "event" => "signup", + "distinct_id" => "u1", + "properties" => { "plan" => "pro" }, + "timestamp" => "2025-06-15T14:30:00.000Z", + "anonymous_id" => "anon-1" + } + ]) + + payload = adapter.calls.first[:body].first + expect(payload).to include( + "event" => "signup", + "distinct_id" => "u1", + "anonymous_id" => "anon-1", + "timestamp" => "2025-06-15T14:30:00.000Z" + ) + expect(payload["properties"]).to include("plan" => "pro") + end + end + + describe "#identify" do + it "posts a single identify from a payload hash" do + client.identify({ + user_id: "u1", + traits: { email: "a@example.com" }, + timestamp: "2025-06-15T14:30:00.000Z" + }) + + payload = adapter.calls.first[:body] + expect(payload).to be_a(Hash) + expect(payload).to include("distinct_id" => "u1", "timestamp" => "2025-06-15T14:30:00.000Z") + expect(payload["traits"]).to include("email" => "a@example.com") + end + + it "posts an array of identify payloads" do + client.identify([ + { user_id: "u1", traits: { email: "a@example.com" }, timestamp: "2025-06-15T14:30:00.000Z" }, + { user_id: "u2", device_id: "device-1", timestamp: "2025-06-15T14:31:00.000Z" } + ]) + + expect(adapter.calls.size).to eq(1) + expect(adapter.calls.first[:path]).to eq("/identify") + payloads = adapter.calls.first[:body] + expect(payloads[0]).to include( + "distinct_id" => "u1", + "environment" => "production", + "timestamp" => "2025-06-15T14:30:00.000Z" + ) + expect(payloads[0]["traits"]).to include("email" => "a@example.com") + expect(payloads[1]).to include("distinct_id" => "u2", "device_id" => "device-1") + end + + it "raises ArgumentError for an empty list" do + expect { client.identify([]) }.to raise_error(ArgumentError, /empty/) + end + + it "raises ArgumentError when a batch item is not a Hash" do + expect { client.identify([nil]) }.to raise_error(ArgumentError, "identifies[0] must be a Hash") + end + + it "accepts string-key hashes in a batch" do + client.identify([ + { "user_id" => "u1", "traits" => { "email" => "a@example.com" }, "timestamp" => "2025-06-15T14:30:00.000Z" } + ]) + + expect(adapter.calls.first[:body].first).to include("distinct_id" => "u1", "timestamp" => "2025-06-15T14:30:00.000Z") + end + end + + describe "#alias" do + it "posts a single alias from a payload hash" do + client.alias({ distinct_id: "anon-1", new_user_id: "u1", timestamp: "2025-06-15T14:30:00.000Z" }) + + payload = adapter.calls.first[:body] + expect(payload).to be_a(Hash) + expect(payload).to include( + "distinct_id" => "anon-1", + "new_user_id" => "u1", + "timestamp" => "2025-06-15T14:30:00.000Z" + ) + end + + it "posts an array of alias payloads" do + client.alias([ + { distinct_id: "anon-1", new_user_id: "u1", timestamp: "2025-06-15T14:30:00.000Z" }, + { distinct_id: "anon-2", new_user_id: "u2" } + ]) + + expect(adapter.calls.size).to eq(1) + expect(adapter.calls.first[:path]).to eq("/alias") + payloads = adapter.calls.first[:body] + expect(payloads[0]).to include( + "distinct_id" => "anon-1", + "new_user_id" => "u1", + "environment" => "production", + "timestamp" => "2025-06-15T14:30:00.000Z" + ) + expect(payloads[1]).to include("distinct_id" => "anon-2", "new_user_id" => "u2") + end + + it "raises ArgumentError for an empty list" do + expect { client.alias([]) }.to raise_error(ArgumentError, /empty/) + end + + it "raises ArgumentError when a batch item is not a Hash" do + expect { client.alias([nil]) }.to raise_error(ArgumentError, "aliases[0] must be a Hash") + end + + it "accepts string-key hashes in a batch" do + client.alias([ + { "distinct_id" => "anon-1", "new_user_id" => "u1", "timestamp" => "2025-06-15T14:30:00.000Z" } + ]) + + expect(adapter.calls.first[:body].first).to include("distinct_id" => "anon-1", "new_user_id" => "u1") + end + end +end diff --git a/spec/altertable_spec.rb b/spec/altertable_spec.rb index 2a6c2c9..c4042da 100644 --- a/spec/altertable_spec.rb +++ b/spec/altertable_spec.rb @@ -43,6 +43,47 @@ end end + describe ".track with a payload hash" do + it "sends a track request" do + response = Altertable.track({ + event: "test_event", + distinct_id: "user_123", + properties: { key: "value" } + }) + expect(response).to include("ok" => true) + end + end + + describe ".track with a payload array" do + it "sends a batch of track events" do + response = Altertable.track([ + { event: "event_a", distinct_id: "user_1" }, + { event: "event_b", distinct_id: "user_2", properties: { key: "value" } } + ]) + expect(response).to include("ok" => true) + end + end + + describe ".identify with a payload array" do + it "sends a batch of identify requests" do + response = Altertable.identify([ + { user_id: "user_1", traits: { email: "one@example.com" } }, + { user_id: "user_2", traits: { email: "two@example.com" } } + ]) + expect(response).to include("ok" => true) + end + end + + describe ".alias with a payload array" do + it "sends a batch of alias requests" do + response = Altertable.alias([ + { distinct_id: "old_1", new_user_id: "new_1" }, + { distinct_id: "old_2", new_user_id: "new_2" } + ]) + expect(response).to include("ok" => true) + end + end + describe "authentication" do context "with wrong API key" do before do