From dcdf10cc71578decda5ed26cfc349a5fcf588d22 Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Tue, 1 Sep 2026 21:19:54 +0200 Subject: [PATCH 1/3] feat(client): add batch track, identify, and alias Send arrays of payloads on the existing endpoints so server-side callers can flush a queue in one request. Co-authored-by: Cursor --- README.md | 23 +++++ lib/altertable.rb | 12 +++ lib/altertable/client.rb | 142 ++++++++++++++++++++------- rbi/altertable.rbi | 20 +++- sig/altertable.rbs | 6 ++ sig/altertable/client.rbs | 8 +- spec/altertable/client_batch_spec.rb | 130 ++++++++++++++++++++++++ spec/altertable_spec.rb | 30 ++++++ 8 files changed, 334 insertions(+), 37 deletions(-) create mode 100644 spec/altertable/client_batch_spec.rb diff --git a/README.md b/README.md index 0341418..3193f2f 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,29 @@ Merge a previous anonymous ID with a newly identified user ID. Altertable.alias('anon_session_456', 'user_123') ``` +### Batching + +Send multiple events, identifies, or aliases in a single request. The API accepts a JSON array on the same `/track`, `/identify`, and `/alias` endpoints. + +```ruby +Altertable.track_batch([ + { event: 'signup', distinct_id: 'user_1', properties: { plan: 'pro' } }, + { event: 'login', distinct_id: 'user_2', timestamp: '2025-06-15T14:30:00.000Z' } +]) + +Altertable.identify_batch([ + { user_id: 'user_1', traits: { email: 'one@example.com' } }, + { user_id: 'user_2', traits: { email: 'two@example.com' } } +]) + +Altertable.alias_batch([ + { distinct_id: 'anon_1', new_user_id: 'user_1' }, + { distinct_id: 'anon_2', new_user_id: 'user_2' } +]) +``` + +Each item uses the same fields as the single-item methods. 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..9e525f9 100644 --- a/lib/altertable.rb +++ b/lib/altertable.rb @@ -22,6 +22,18 @@ def alias(distinct_id, new_user_id, **options) client.alias(distinct_id, new_user_id, **options) end + def track_batch(events) + client.track_batch(events) + end + + def identify_batch(identifies) + client.identify_batch(identifies) + end + + def alias_batch(aliases) + client.alias_batch(aliases) + end + def client raise ConfigurationError, "Altertable client not initialized. Call Altertable.init(api_key) first." unless @client diff --git a/lib/altertable/client.rb b/lib/altertable/client.rb index 7ff285d..d7a729a 100644 --- a/lib/altertable/client.rb +++ b/lib/altertable/client.rb @@ -32,50 +32,30 @@ def initialize(api_key, options = {}) 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", track_payload(event, distinct_id, options)) + end - post("/track", payload) + def track_batch(events) + payloads = map_batch(events, "events") { |item| track_payload_from_item(item) } + post("/track", payloads) 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", identify_payload(user_id, options)) + end - post("/identify", payload) + def identify_batch(identifies) + payloads = map_batch(identifies, "identifies") { |item| identify_payload_from_item(item) } + post("/identify", payloads) 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", alias_payload(distinct_id, new_user_id, options)) + end - post("/alias", payload) + def alias_batch(aliases) + payloads = map_batch(aliases, "aliases") { |item| alias_payload_from_item(item) } + post("/alias", payloads) end private @@ -107,6 +87,98 @@ 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.map(&block) + 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..600d55b 100644 --- a/rbi/altertable.rbi +++ b/rbi/altertable.rbi @@ -37,12 +37,21 @@ module Altertable sig { params(event: String, distinct_id: String, options: T.untyped).returns(T.untyped) } def track(event, distinct_id, **options); end + sig { params(events: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } + def track_batch(events); end + sig { params(user_id: String, options: T.untyped).returns(T.untyped) } def identify(user_id, **options); end + sig { params(identifies: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } + def identify_batch(identifies); 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(aliases: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } + def alias_batch(aliases); end + private sig { params(name: T.nilable(Symbol), options: T::Hash[Symbol, T.untyped]).returns(T.untyped) } @@ -51,7 +60,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) } @@ -127,12 +136,21 @@ module Altertable sig { params(event: String, distinct_id: String, options: T.untyped).returns(T.untyped) } def self.track(event, distinct_id, **options); end + sig { params(events: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } + def self.track_batch(events); end + sig { params(user_id: String, options: T.untyped).returns(T.untyped) } def self.identify(user_id, **options); end + sig { params(identifies: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } + def self.identify_batch(identifies); 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(aliases: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } + def self.alias_batch(aliases); end + sig { returns(Client) } def self.client; end end diff --git a/sig/altertable.rbs b/sig/altertable.rbs index 6954ef2..9c50592 100644 --- a/sig/altertable.rbs +++ b/sig/altertable.rbs @@ -5,9 +5,15 @@ module Altertable def self.track: (String event, String distinct_id, **untyped options) -> untyped + def self.track_batch: (::Array[::Hash[Symbol | String, untyped]] events) -> untyped + def self.identify: (String user_id, **untyped options) -> untyped + def self.identify_batch: (::Array[::Hash[Symbol | String, untyped]] identifies) -> untyped + def self.alias: (String distinct_id, String new_user_id, **untyped options) -> untyped + def self.alias_batch: (::Array[::Hash[Symbol | String, untyped]] aliases) -> untyped + def self.client: () -> Client end diff --git a/sig/altertable/client.rbs b/sig/altertable/client.rbs index 9393e8e..641e7f9 100644 --- a/sig/altertable/client.rbs +++ b/sig/altertable/client.rbs @@ -17,15 +17,21 @@ module Altertable def track: (String event, String distinct_id, **untyped options) -> untyped + def track_batch: (::Array[::Hash[Symbol | String, untyped]] events) -> untyped + def identify: (String user_id, **untyped options) -> untyped + def identify_batch: (::Array[::Hash[Symbol | String, untyped]] identifies) -> untyped + def alias: (String distinct_id, String new_user_id, **untyped options) -> untyped + def alias_batch: (::Array[::Hash[Symbol | String, untyped]] aliases) -> 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..bf3cb36 --- /dev/null +++ b/spec/altertable/client_batch_spec.rb @@ -0,0 +1,130 @@ +# 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_batch" do + it "posts an array of track payloads in one request" do + client.track_batch([ + { 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_batch([{ 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_batch([{ event: "signup", distinct_id: "u1" }]) + expect(response).to include("ok" => true) + end + + it "raises ArgumentError for an empty list" do + expect { client.track_batch([]) }.to raise_error(ArgumentError, /empty/) + expect(adapter.calls).to be_empty + end + + it "raises ArgumentError when event is missing" do + expect { client.track_batch([{ distinct_id: "u1" }]) }.to raise_error(ArgumentError, /event/) + end + end + + describe "#identify_batch" do + it "posts an array of identify payloads" do + client.identify_batch([ + { 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_batch([]) }.to raise_error(ArgumentError, /empty/) + end + end + + describe "#alias_batch" do + it "posts an array of alias payloads" do + client.alias_batch([ + { 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_batch([]) }.to raise_error(ArgumentError, /empty/) + end + end +end diff --git a/spec/altertable_spec.rb b/spec/altertable_spec.rb index 2a6c2c9..47bc336 100644 --- a/spec/altertable_spec.rb +++ b/spec/altertable_spec.rb @@ -43,6 +43,36 @@ end end + describe ".track_batch" do + it "sends a batch of track events" do + response = Altertable.track_batch([ + { 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_batch" do + it "sends a batch of identify requests" do + response = Altertable.identify_batch([ + { 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_batch" do + it "sends a batch of alias requests" do + response = Altertable.alias_batch([ + { 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 From 9d2be7671e4ecf72043cda5759f882f5f76fe678 Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Fri, 4 Sep 2026 11:00:13 +0200 Subject: [PATCH 2/3] feat(client): overload track, identify, and alias for payloads Accept a single payload hash or an array on the existing methods instead of separate *_batch APIs. Co-authored-by: Cursor --- README.md | 48 +++++++++-------- lib/altertable.rb | 16 +----- lib/altertable/client.rb | 55 ++++++++++++------- rbi/altertable.rbi | 38 ++++--------- sig/altertable.rbs | 12 ++--- sig/altertable/client.rbs | 12 ++--- spec/altertable/client_batch_spec.rb | 80 +++++++++++++++++++++++----- spec/altertable_spec.rb | 23 +++++--- 8 files changed, 170 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 3193f2f..3969de1 100644 --- a/README.md +++ b/README.md @@ -47,60 +47,66 @@ 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') -``` - -### Batching - -Send multiple events, identifies, or aliases in a single request. The API accepts a JSON array on the same `/track`, `/identify`, and `/alias` endpoints. - -```ruby -Altertable.track_batch([ - { event: 'signup', distinct_id: 'user_1', properties: { plan: 'pro' } }, - { event: 'login', distinct_id: 'user_2', timestamp: '2025-06-15T14:30:00.000Z' } -]) - -Altertable.identify_batch([ - { user_id: 'user_1', traits: { email: 'one@example.com' } }, - { user_id: 'user_2', traits: { email: 'two@example.com' } } -]) -Altertable.alias_batch([ +Altertable.alias([ { distinct_id: 'anon_1', new_user_id: 'user_1' }, { distinct_id: 'anon_2', new_user_id: 'user_2' } ]) ``` -Each item uses the same fields as the single-item methods. Omitted `timestamp` values default to the current time as ISO 8601. +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 diff --git a/lib/altertable.rb b/lib/altertable.rb index 9e525f9..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,22 +18,10 @@ 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 - def track_batch(events) - client.track_batch(events) - end - - def identify_batch(identifies) - client.identify_batch(identifies) - end - - def alias_batch(aliases) - client.alias_batch(aliases) - end - def client raise ConfigurationError, "Altertable client not initialized. Call Altertable.init(api_key) first." unless @client diff --git a/lib/altertable/client.rb b/lib/altertable/client.rb index d7a729a..7b6f40f 100644 --- a/lib/altertable/client.rb +++ b/lib/altertable/client.rb @@ -31,31 +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) - post("/track", track_payload(event, distinct_id, options)) - end - - def track_batch(events) - payloads = map_batch(events, "events") { |item| track_payload_from_item(item) } - post("/track", payloads) + 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) - post("/identify", identify_payload(user_id, options)) - end - - def identify_batch(identifies) - payloads = map_batch(identifies, "identifies") { |item| identify_payload_from_item(item) } - post("/identify", payloads) - end - - def alias(distinct_id, new_user_id, **options) - post("/alias", alias_payload(distinct_id, new_user_id, options)) + 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_batch(aliases) - payloads = map_batch(aliases, "aliases") { |item| alias_payload_from_item(item) } - post("/alias", payloads) + 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 @@ -93,6 +102,12 @@ def map_batch(items, name, &block) 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] diff --git a/rbi/altertable.rbi b/rbi/altertable.rbi index 600d55b..88962c1 100644 --- a/rbi/altertable.rbi +++ b/rbi/altertable.rbi @@ -34,23 +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(events: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } - def track_batch(events); 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(identifies: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } - def identify_batch(identifies); 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(aliases: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } - def alias_batch(aliases); 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 @@ -133,23 +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(events: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } - def self.track_batch(events); 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(identifies: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } - def self.identify_batch(identifies); 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(aliases: T::Array[T::Hash[T.any(Symbol, String), T.untyped]]).returns(T.untyped) } - def self.alias_batch(aliases); 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 9c50592..821b70e 100644 --- a/sig/altertable.rbs +++ b/sig/altertable.rbs @@ -4,16 +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 - - def self.track_batch: (::Array[::Hash[Symbol | String, untyped]] events) -> 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 - - def self.identify_batch: (::Array[::Hash[Symbol | String, untyped]] identifies) -> 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 - - def self.alias_batch: (::Array[::Hash[Symbol | String, untyped]] aliases) -> 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 641e7f9..b511464 100644 --- a/sig/altertable/client.rbs +++ b/sig/altertable/client.rbs @@ -16,16 +16,16 @@ module Altertable def initialize: (String api_key, ?::Hash[Symbol, untyped] options) -> void def track: (String event, String distinct_id, **untyped options) -> untyped - - def track_batch: (::Array[::Hash[Symbol | String, untyped]] events) -> 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 - - def identify_batch: (::Array[::Hash[Symbol | String, untyped]] identifies) -> 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 - - def alias_batch: (::Array[::Hash[Symbol | String, untyped]] aliases) -> untyped + | (::Hash[Symbol | String, untyped] alias_payload, **untyped options) -> untyped + | (::Array[::Hash[Symbol | String, untyped]] alias_payloads, **untyped options) -> untyped private diff --git a/spec/altertable/client_batch_spec.rb b/spec/altertable/client_batch_spec.rb index bf3cb36..8376834 100644 --- a/spec/altertable/client_batch_spec.rb +++ b/spec/altertable/client_batch_spec.rb @@ -31,9 +31,38 @@ def post(path, body: nil, params: {}) end end - describe "#track_batch" do + 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_batch([ + 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" } ]) @@ -59,30 +88,43 @@ def post(path, body: nil, params: {}) end it "defaults omitted timestamps to ISO 8601" do - client.track_batch([{ event: "signup", distinct_id: "u1" }]) + 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_batch([{ event: "signup", distinct_id: "u1" }]) + 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_batch([]) }.to raise_error(ArgumentError, /empty/) + expect { client.track([]) }.to raise_error(ArgumentError, /empty/) expect(adapter.calls).to be_empty end - it "raises ArgumentError when event is missing" do - expect { client.track_batch([{ distinct_id: "u1" }]) }.to raise_error(ArgumentError, /event/) + it "raises ArgumentError when event is missing from a payload" do + expect { client.track([{ distinct_id: "u1" }]) }.to raise_error(ArgumentError, /event/) end end - describe "#identify_batch" do + 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_batch([ + 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" } ]) @@ -100,13 +142,25 @@ def post(path, body: nil, params: {}) end it "raises ArgumentError for an empty list" do - expect { client.identify_batch([]) }.to raise_error(ArgumentError, /empty/) + expect { client.identify([]) }.to raise_error(ArgumentError, /empty/) end end - describe "#alias_batch" do + 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_batch([ + 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" } ]) @@ -124,7 +178,7 @@ def post(path, body: nil, params: {}) end it "raises ArgumentError for an empty list" do - expect { client.alias_batch([]) }.to raise_error(ArgumentError, /empty/) + expect { client.alias([]) }.to raise_error(ArgumentError, /empty/) end end end diff --git a/spec/altertable_spec.rb b/spec/altertable_spec.rb index 47bc336..c4042da 100644 --- a/spec/altertable_spec.rb +++ b/spec/altertable_spec.rb @@ -43,9 +43,20 @@ end end - describe ".track_batch" do + 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_batch([ + response = Altertable.track([ { event: "event_a", distinct_id: "user_1" }, { event: "event_b", distinct_id: "user_2", properties: { key: "value" } } ]) @@ -53,9 +64,9 @@ end end - describe ".identify_batch" do + describe ".identify with a payload array" do it "sends a batch of identify requests" do - response = Altertable.identify_batch([ + response = Altertable.identify([ { user_id: "user_1", traits: { email: "one@example.com" } }, { user_id: "user_2", traits: { email: "two@example.com" } } ]) @@ -63,9 +74,9 @@ end end - describe ".alias_batch" do + describe ".alias with a payload array" do it "sends a batch of alias requests" do - response = Altertable.alias_batch([ + response = Altertable.alias([ { distinct_id: "old_1", new_user_id: "new_1" }, { distinct_id: "old_2", new_user_id: "new_2" } ]) From f206415fce6037cfed8545c76e0807eca89a3213 Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Fri, 4 Sep 2026 11:04:30 +0200 Subject: [PATCH 3/3] fix(client): reject non-hash batch items with an indexed error Raise ArgumentError like events[0] must be a Hash instead of NoMethodError on malformed entries. Co-authored-by: Cursor --- lib/altertable/client.rb | 4 +++ spec/altertable/client_batch_spec.rb | 51 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/altertable/client.rb b/lib/altertable/client.rb index 7b6f40f..007e1a8 100644 --- a/lib/altertable/client.rb +++ b/lib/altertable/client.rb @@ -99,6 +99,10 @@ def try_require(gem_name) 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 diff --git a/spec/altertable/client_batch_spec.rb b/spec/altertable/client_batch_spec.rb index 8376834..44311b8 100644 --- a/spec/altertable/client_batch_spec.rb +++ b/spec/altertable/client_batch_spec.rb @@ -107,6 +107,33 @@ def post(path, body: nil, params: {}) 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 @@ -144,6 +171,18 @@ def post(path, body: nil, params: {}) 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 @@ -180,5 +219,17 @@ def post(path, body: nil, params: {}) 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