Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/altertable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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

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

Expand Down
171 changes: 131 additions & 40 deletions lib/altertable/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
redox marked this conversation as resolved.
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)
Expand Down
22 changes: 11 additions & 11 deletions rbi/altertable.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) }
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions sig/altertable.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion sig/altertable/client.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading