trevosdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +31 -0
- data/lib/trevosdk/batcher.rb +151 -0
- data/lib/trevosdk/client.rb +198 -0
- data/lib/trevosdk/core.rb +159 -0
- data/lib/trevosdk/poller.rb +92 -0
- data/lib/trevosdk/transport.rb +54 -0
- data/lib/trevosdk/version.rb +5 -0
- data/lib/trevosdk.rb +25 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a962376d1586309cd51a06b89ebc86bf1fd6e150c04e74aede22a6565685ec40
|
|
4
|
+
data.tar.gz: 30d9cc18e9c4b11a77230525f6402a6152dc3c21cb063ede29c3a7cc76fa4609
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 82076d6c8de3bf5f47a40be0b6a75581a7e9a24e4f04e7cf09c8814e60c6e68244078a83c059fefea83ee4770694bf28938d9d97eef67bb86814f6ba24f85c8e
|
|
7
|
+
data.tar.gz: 18f482f337bb9d52d6c0bed6e505d4879fd8aacb5ccf96fdd5c652389a5c23cf4006935568c77eeec7bbc5b8a1efc0e2a25429f97e039549a0b7870792a4f72d
|
data/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# trevosdk
|
|
2
|
+
|
|
3
|
+
Trevo server SDK for Ruby — deterministic variant assignment and event tracking
|
|
4
|
+
for [Trevo](https://trevosdk.com) experiments. Zero dependencies; Ruby 3.2+.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
gem install trevosdk
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
# config/initializers/trevosdk.rb (Rails) or anywhere at boot
|
|
12
|
+
Trevosdk.init(ENV.fetch("TREVO_SECRET_KEY"))
|
|
13
|
+
|
|
14
|
+
variant = Trevosdk.client.get_variant("checkout-cta", user_id: current_user.id)
|
|
15
|
+
if variant == "treatment"
|
|
16
|
+
# alternate experience
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Trevosdk.client.track("checkout_started", user_id: current_user.id, properties: {plan: "pro"})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`get_variant` reads an immutable in-memory config snapshot — no network call — safe
|
|
23
|
+
from Rails controllers, Sidekiq jobs, and scripts alike. Config refresh and event
|
|
24
|
+
delivery run on background threads; events flush at process exit, or deterministically
|
|
25
|
+
via `flush` / `close`.
|
|
26
|
+
|
|
27
|
+
Assignment is byte-identical to every other Trevo SDK: the same user gets the same
|
|
28
|
+
variant in the browser and on the backend, verified against the shared conformance
|
|
29
|
+
corpus.
|
|
30
|
+
|
|
31
|
+
Full docs at [docs.trevosdk.com](https://docs.trevosdk.com).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "transport"
|
|
7
|
+
|
|
8
|
+
module Trevosdk
|
|
9
|
+
# Bounded background delivery mirroring the node SDK's EventBatcher semantics.
|
|
10
|
+
class EventBatcher
|
|
11
|
+
def initialize(ingestion_url:, secret_key:, max_batch_size:, flush_interval_s:, transport:, on_error:)
|
|
12
|
+
@ingestion_url = ingestion_url
|
|
13
|
+
@secret_key = secret_key
|
|
14
|
+
@batch_size = [max_batch_size, SERVER_MAX_BATCH_SIZE].min
|
|
15
|
+
@flush_interval_s = flush_interval_s
|
|
16
|
+
@transport = transport
|
|
17
|
+
@on_error = on_error
|
|
18
|
+
|
|
19
|
+
@queue = []
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@send_mutex = Mutex.new
|
|
22
|
+
@wake_mutex = Mutex.new
|
|
23
|
+
@wake_cv = ConditionVariable.new
|
|
24
|
+
@wake_flag = false
|
|
25
|
+
@stopped = false
|
|
26
|
+
@dropped = 0
|
|
27
|
+
@worker = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def start
|
|
31
|
+
return unless @worker.nil?
|
|
32
|
+
@worker = Thread.new { run }
|
|
33
|
+
@worker.name = "trevosdk-batcher"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enqueue(event)
|
|
37
|
+
first_drop = false
|
|
38
|
+
over_batch = false
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
return if @stopped
|
|
41
|
+
if @queue.length >= MAX_QUEUE_SIZE
|
|
42
|
+
# Drop oldest: an outage should cost the stalest events, not the newest.
|
|
43
|
+
@queue.shift
|
|
44
|
+
@dropped += 1
|
|
45
|
+
first_drop = @dropped == 1
|
|
46
|
+
end
|
|
47
|
+
@queue.push(event)
|
|
48
|
+
over_batch = @queue.length >= @batch_size
|
|
49
|
+
end
|
|
50
|
+
if first_drop
|
|
51
|
+
@on_error.call(RuntimeError.new("Trevo event queue full at #{MAX_QUEUE_SIZE}; dropping oldest events"))
|
|
52
|
+
end
|
|
53
|
+
wake! if over_batch
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sends everything queued; raises on delivery failure so callers get a real guarantee.
|
|
57
|
+
def flush
|
|
58
|
+
@send_mutex.synchronize { drain }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stops the worker and flushes once; never raises — this runs from exit hooks.
|
|
62
|
+
def shutdown
|
|
63
|
+
@mutex.synchronize { @stopped = true }
|
|
64
|
+
wake!
|
|
65
|
+
@worker&.join(@flush_interval_s + 1)
|
|
66
|
+
@worker = nil
|
|
67
|
+
begin
|
|
68
|
+
flush
|
|
69
|
+
rescue => error
|
|
70
|
+
@on_error.call(error)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def wake!
|
|
77
|
+
@wake_mutex.synchronize do
|
|
78
|
+
@wake_flag = true
|
|
79
|
+
@wake_cv.signal
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def run
|
|
84
|
+
loop do
|
|
85
|
+
@wake_mutex.synchronize do
|
|
86
|
+
@wake_cv.wait(@wake_mutex, @flush_interval_s) unless @wake_flag
|
|
87
|
+
@wake_flag = false
|
|
88
|
+
end
|
|
89
|
+
return if @mutex.synchronize { @stopped }
|
|
90
|
+
begin
|
|
91
|
+
flush
|
|
92
|
+
rescue => error
|
|
93
|
+
@on_error.call(error)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def drain
|
|
99
|
+
pending = @mutex.synchronize do
|
|
100
|
+
drained = @queue
|
|
101
|
+
@queue = []
|
|
102
|
+
drained
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
pending.each_slice(@batch_size).with_index do |batch, index|
|
|
106
|
+
status = begin
|
|
107
|
+
send_batch(batch)
|
|
108
|
+
rescue
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
next if !status.nil? && (200..299).cover?(status)
|
|
112
|
+
|
|
113
|
+
start = index * @batch_size
|
|
114
|
+
requeued =
|
|
115
|
+
if retryable?(status)
|
|
116
|
+
pending[start..]
|
|
117
|
+
else
|
|
118
|
+
# A rejected batch will be rejected identically forever; drop it, keep the rest.
|
|
119
|
+
@on_error.call(
|
|
120
|
+
RuntimeError.new(
|
|
121
|
+
"Trevo dropped #{batch.length} event(s): ingestion rejected them with status #{status.inspect}"
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
pending[(start + batch.length)..]
|
|
125
|
+
end
|
|
126
|
+
@mutex.synchronize { @queue.unshift(*requeued) }
|
|
127
|
+
raise "Trevo event delivery failed with status #{status.inspect}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def retryable?(status)
|
|
132
|
+
return true if status.nil?
|
|
133
|
+
return true if [408, 429].include?(status)
|
|
134
|
+
status >= 500
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def send_batch(events)
|
|
138
|
+
# sentAt is stamped at send, so a batch re-queued through an outage stays correctable.
|
|
139
|
+
body = JSON.generate({events: events, sentAt: Time.now.utc.iso8601(3)})
|
|
140
|
+
response = @transport.call(
|
|
141
|
+
TransportRequest.new(
|
|
142
|
+
method: "POST",
|
|
143
|
+
url: @ingestion_url,
|
|
144
|
+
headers: Trevosdk.auth_headers(@secret_key),
|
|
145
|
+
body: body
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
response.status
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "batcher"
|
|
7
|
+
require_relative "core"
|
|
8
|
+
require_relative "poller"
|
|
9
|
+
require_relative "transport"
|
|
10
|
+
require_relative "version"
|
|
11
|
+
|
|
12
|
+
module Trevosdk
|
|
13
|
+
# Server client: deterministic assignment and event tracking for any Ruby runtime.
|
|
14
|
+
#
|
|
15
|
+
# Evaluation reads an immutable config snapshot and never touches the network,
|
|
16
|
+
# so calls are safe from Rails controllers, Sidekiq jobs, and scripts alike.
|
|
17
|
+
class Client
|
|
18
|
+
EXPOSURE_EVENT = "$experiment_exposure"
|
|
19
|
+
|
|
20
|
+
def initialize(secret_key, bootstrap_config: nil, config_url: CONFIG_URL,
|
|
21
|
+
ingestion_url: INGESTION_URL, alias_url: nil,
|
|
22
|
+
poll_interval_s: DEFAULT_POLL_INTERVAL_S, flush_interval_s: DEFAULT_FLUSH_INTERVAL_S,
|
|
23
|
+
max_batch_size: DEFAULT_MAX_BATCH_SIZE, force_variants: nil,
|
|
24
|
+
transport: nil, on_error: nil)
|
|
25
|
+
raise ArgumentError, "Trevosdk::Client requires a secret_key" if secret_key.to_s.empty?
|
|
26
|
+
if !bootstrap_config.nil? && !bootstrap_config.is_a?(Array)
|
|
27
|
+
raise ArgumentError,
|
|
28
|
+
"bootstrap_config must be an array of experiment configs — pass " \
|
|
29
|
+
'response["experiments"], not the whole config response'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
@secret_key = secret_key
|
|
33
|
+
@transport = transport || DEFAULT_TRANSPORT
|
|
34
|
+
@on_error = on_error || ->(error) { warn("[Trevo] #{error.message}") }
|
|
35
|
+
@alias_url = alias_url || self.class.derive_alias_url(ingestion_url)
|
|
36
|
+
|
|
37
|
+
@experiments = (bootstrap_config || []).to_h { |config| [config["experimentKey"], config] }
|
|
38
|
+
@exposures = ExposureDeduper.new(SERVER_EXPOSURE_DEDUP_MAX_KEYS)
|
|
39
|
+
@exposures_mutex = Mutex.new
|
|
40
|
+
@forced = self.class.parse_force_variants(ENV[FORCE_VARIANTS_ENV])
|
|
41
|
+
.merge((force_variants || {}).transform_keys(&:to_s))
|
|
42
|
+
|
|
43
|
+
@batcher = EventBatcher.new(
|
|
44
|
+
ingestion_url: ingestion_url,
|
|
45
|
+
secret_key: secret_key,
|
|
46
|
+
max_batch_size: [1, max_batch_size].max,
|
|
47
|
+
flush_interval_s: [MIN_FLUSH_INTERVAL_S, flush_interval_s].max,
|
|
48
|
+
transport: @transport,
|
|
49
|
+
on_error: @on_error
|
|
50
|
+
)
|
|
51
|
+
@poller = ConfigPoller.new(
|
|
52
|
+
config_url: config_url,
|
|
53
|
+
secret_key: secret_key,
|
|
54
|
+
interval_s: [MIN_POLL_INTERVAL_S, poll_interval_s].max,
|
|
55
|
+
transport: @transport,
|
|
56
|
+
on_config: ->(configs) { swap_config(configs) },
|
|
57
|
+
on_error: @on_error
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@start_mutex = Mutex.new
|
|
61
|
+
@started = false
|
|
62
|
+
@closed = false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Alias lives on the same service as events, so an ingestion override must move alias too.
|
|
66
|
+
def self.derive_alias_url(ingestion_url)
|
|
67
|
+
return ALIAS_URL if ingestion_url == INGESTION_URL
|
|
68
|
+
trimmed = ingestion_url.sub(%r{/+\z}, "")
|
|
69
|
+
return "#{trimmed.delete_suffix("/events")}/alias" if trimmed.end_with?("/events")
|
|
70
|
+
ALIAS_URL
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.parse_force_variants(raw)
|
|
74
|
+
return {} if raw.nil? || raw.empty?
|
|
75
|
+
raw.split(",").each_with_object({}) do |pair, parsed|
|
|
76
|
+
separator = pair.index(":")
|
|
77
|
+
next if separator.nil? || separator.zero? || separator == pair.length - 1
|
|
78
|
+
key = pair[...separator].strip
|
|
79
|
+
variant = pair[(separator + 1)..].strip
|
|
80
|
+
parsed[key] = variant unless key.empty? || variant.empty?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def get_variant(experiment_key, user_id: nil, anonymous_id: nil, track_exposure: true)
|
|
85
|
+
return Core::CONTROL_VARIANT if experiment_key.to_s.empty?
|
|
86
|
+
ensure_started
|
|
87
|
+
|
|
88
|
+
config = @experiments[experiment_key]
|
|
89
|
+
|
|
90
|
+
# Overrides win, but only for a variant the experiment has, and never
|
|
91
|
+
# record an exposure — see docs/sdk/bucketing-spec.md §6.
|
|
92
|
+
override = @forced[experiment_key]
|
|
93
|
+
if override && config && config["variants"].any? { |v| v["name"] == override }
|
|
94
|
+
return override
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# An empty-string user_id must fall through to the anonymous id the
|
|
98
|
+
# browser is bucketing with.
|
|
99
|
+
bucketing_id = user_id.to_s.empty? ? anonymous_id : user_id
|
|
100
|
+
return Core::CONTROL_VARIANT if bucketing_id.to_s.empty? || config.nil?
|
|
101
|
+
|
|
102
|
+
variant = Core.assign_variant(bucketing_id, config)
|
|
103
|
+
|
|
104
|
+
if track_exposure
|
|
105
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
106
|
+
should_log = @exposures_mutex.synchronize do
|
|
107
|
+
@exposures.should_log?(bucketing_id, experiment_key, variant, now_ms)
|
|
108
|
+
end
|
|
109
|
+
if should_log
|
|
110
|
+
enqueue_event(
|
|
111
|
+
EXPOSURE_EVENT, user_id, anonymous_id,
|
|
112
|
+
{"experimentKey" => experiment_key, "variantName" => variant}, nil
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
variant
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def track(event_name, user_id: nil, anonymous_id: nil, properties: nil, insert_id: nil)
|
|
121
|
+
enqueue_event(event_name, user_id, anonymous_id, properties, insert_id)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def alias(anonymous_id, user_id)
|
|
125
|
+
ensure_started
|
|
126
|
+
if anonymous_id.to_s.empty? || user_id.to_s.empty?
|
|
127
|
+
raise ArgumentError, "alias requires both an anonymous_id and a user_id"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
response = @transport.call(
|
|
131
|
+
TransportRequest.new(
|
|
132
|
+
method: "POST",
|
|
133
|
+
url: @alias_url,
|
|
134
|
+
headers: Trevosdk.auth_headers(@secret_key),
|
|
135
|
+
body: JSON.generate({anonymousId: anonymous_id, userId: user_id})
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
if response.status == 409
|
|
139
|
+
raise "Trevo alias rejected: anonymousId \"#{anonymous_id}\" is already linked to a different user"
|
|
140
|
+
end
|
|
141
|
+
raise "Trevo alias failed with status #{response.status}" unless (200..299).cover?(response.status)
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ready(timeout_s = nil)
|
|
146
|
+
ensure_started
|
|
147
|
+
@poller.wait_ready(timeout_s)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def flush
|
|
151
|
+
@batcher.flush
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def close
|
|
155
|
+
return if @closed
|
|
156
|
+
@closed = true
|
|
157
|
+
@poller.stop
|
|
158
|
+
@batcher.shutdown
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def swap_config(configs)
|
|
164
|
+
@experiments = configs.to_h { |config| [config["experimentKey"], config] }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def ensure_started
|
|
168
|
+
return if @started
|
|
169
|
+
@start_mutex.synchronize do
|
|
170
|
+
return if @started || @closed
|
|
171
|
+
@batcher.start
|
|
172
|
+
@poller.start
|
|
173
|
+
at_exit { close }
|
|
174
|
+
@started = true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def enqueue_event(event_name, user_id, anonymous_id, properties, insert_id)
|
|
179
|
+
built = Core.build_event(
|
|
180
|
+
event_name,
|
|
181
|
+
timestamp: Time.now.utc.iso8601(3),
|
|
182
|
+
user_id: user_id,
|
|
183
|
+
anonymous_id: anonymous_id,
|
|
184
|
+
properties: properties,
|
|
185
|
+
sdk_version: "ruby-#{VERSION}",
|
|
186
|
+
# Stamped at enqueue so a redelivered batch is stored once.
|
|
187
|
+
insert_id: insert_id || SecureRandom.uuid
|
|
188
|
+
)
|
|
189
|
+
unless built.ok
|
|
190
|
+
@on_error.call(RuntimeError.new("Trevo track(\"#{event_name}\") ignored: #{built.reason}"))
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
ensure_started
|
|
195
|
+
@batcher.enqueue(built.event)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Trevosdk
|
|
6
|
+
# Pure evaluation core — no I/O, no clock reads; time is a parameter.
|
|
7
|
+
# Frozen contract: must match packages/contracts/src/corpus/vectors.json exactly.
|
|
8
|
+
module Core
|
|
9
|
+
CONTROL_VARIANT = "control"
|
|
10
|
+
BUCKET_SPACE = 10_000
|
|
11
|
+
TREVO_ID_COOKIE = "trevo_id"
|
|
12
|
+
|
|
13
|
+
MAX_EVENT_NAME_LENGTH = 500
|
|
14
|
+
EVENT_NAME_RE = /\A[A-Za-z0-9_.$-]+\z/
|
|
15
|
+
MAX_PROPERTIES_BYTES = 8_192
|
|
16
|
+
|
|
17
|
+
EXPOSURE_DEDUP_WINDOW_MS = 600_000
|
|
18
|
+
EXPOSURE_DEDUP_MAX_KEYS = 1_000
|
|
19
|
+
|
|
20
|
+
FNV_OFFSET_BASIS_32 = 0x811c9dc5
|
|
21
|
+
FNV_PRIME_32 = 0x01000193
|
|
22
|
+
MASK_32 = 0xffffffff
|
|
23
|
+
|
|
24
|
+
ParseResult = Struct.new(:success, :experiments, :error)
|
|
25
|
+
BuildEventResult = Struct.new(:ok, :event, :reason)
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# FNV-1a over UTF-16 code units (not bytes) — matches the reference JS SDK.
|
|
30
|
+
# Invalid bytes hash as U+FFFD instead of raising: such strings have no JS
|
|
31
|
+
# equivalent, so deterministic assignment beats crashing the request.
|
|
32
|
+
def fnv1a32(value)
|
|
33
|
+
hash = FNV_OFFSET_BASIS_32
|
|
34
|
+
units = value.encode(Encoding::UTF_16LE, invalid: :replace, undef: :replace).unpack("v*")
|
|
35
|
+
units.each do |unit|
|
|
36
|
+
hash = ((hash ^ unit) * FNV_PRIME_32) & MASK_32
|
|
37
|
+
end
|
|
38
|
+
hash
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def assign_variant(bucketing_id, config, on_warn = nil)
|
|
42
|
+
experiment_key = config["experimentKey"]
|
|
43
|
+
variants = config["variants"]
|
|
44
|
+
|
|
45
|
+
if variants.empty?
|
|
46
|
+
on_warn&.call(%([TrevoSDK] Experiment "#{experiment_key}" has no variants. Returning "control".))
|
|
47
|
+
return CONTROL_VARIANT
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
total = variants.sum { |variant| variant["trafficSplit"] }
|
|
51
|
+
if (total - 100).abs > 0.01
|
|
52
|
+
on_warn&.call(
|
|
53
|
+
%([TrevoSDK] Experiment "#{experiment_key}" trafficSplit sums to #{total}, ) +
|
|
54
|
+
"expected 100. Assignment may be biased."
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
bucket = fnv1a32("#{bucketing_id}:#{experiment_key}") % BUCKET_SPACE
|
|
59
|
+
|
|
60
|
+
cumulative = 0
|
|
61
|
+
last_variant_name = nil
|
|
62
|
+
variants.each do |variant|
|
|
63
|
+
cumulative += variant["trafficSplit"] * 100
|
|
64
|
+
return variant["name"].to_s if bucket < cumulative
|
|
65
|
+
last_variant_name = variant["name"].to_s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
last_variant_name || CONTROL_VARIANT
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def valid_variant?(value)
|
|
72
|
+
return false unless value.is_a?(Hash)
|
|
73
|
+
name = value["name"]
|
|
74
|
+
split = value["trafficSplit"]
|
|
75
|
+
return false unless name.is_a?(String) && !name.empty?
|
|
76
|
+
# An integral float (50.0) is accepted because JS cannot distinguish it from 50.
|
|
77
|
+
split.is_a?(Integer) || (split.is_a?(Float) && split % 1 == 0)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def valid_experiment_config?(value)
|
|
81
|
+
return false unless value.is_a?(Hash)
|
|
82
|
+
key = value["experimentKey"]
|
|
83
|
+
variants = value["variants"]
|
|
84
|
+
return false unless key.is_a?(String) && !key.empty?
|
|
85
|
+
return false unless variants.is_a?(Array) && variants.length >= 2
|
|
86
|
+
variants.all? { |variant| valid_variant?(variant) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse_config_response(body)
|
|
90
|
+
return ParseResult.new(false, [], "Response is not an object") unless body.is_a?(Hash)
|
|
91
|
+
experiments = body["experiments"]
|
|
92
|
+
return ParseResult.new(false, [], "Missing experiments array") unless experiments.is_a?(Array)
|
|
93
|
+
unless experiments.all? { |experiment| valid_experiment_config?(experiment) }
|
|
94
|
+
return ParseResult.new(false, [], "Invalid experiment config in response")
|
|
95
|
+
end
|
|
96
|
+
ParseResult.new(true, experiments, nil)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_event(event_name, timestamp:, user_id: nil, anonymous_id: nil,
|
|
100
|
+
properties: nil, sdk_version: nil, insert_id: nil)
|
|
101
|
+
unless event_name.is_a?(String) && !event_name.strip.empty?
|
|
102
|
+
return BuildEventResult.new(false, nil, "empty-name")
|
|
103
|
+
end
|
|
104
|
+
return BuildEventResult.new(false, nil, "name-too-long") if event_name.length > MAX_EVENT_NAME_LENGTH
|
|
105
|
+
return BuildEventResult.new(false, nil, "name-charset") unless EVENT_NAME_RE.match?(event_name)
|
|
106
|
+
|
|
107
|
+
snapshot = nil
|
|
108
|
+
unless properties.nil?
|
|
109
|
+
begin
|
|
110
|
+
serialized = JSON.generate(properties)
|
|
111
|
+
rescue JSON::NestingError, JSON::GeneratorError, SystemStackError
|
|
112
|
+
return BuildEventResult.new(false, nil, "properties-unserializable")
|
|
113
|
+
end
|
|
114
|
+
return BuildEventResult.new(false, nil, "properties-too-large") if serialized.length > MAX_PROPERTIES_BYTES
|
|
115
|
+
# Round trip both validates and deep-copies, isolating the caller from later mutation.
|
|
116
|
+
snapshot = JSON.parse(serialized)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if (user_id.nil? || user_id.empty?) && (anonymous_id.nil? || anonymous_id.empty?)
|
|
120
|
+
return BuildEventResult.new(false, nil, "no-identity")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
event = {"event" => event_name}
|
|
124
|
+
event["userId"] = user_id unless user_id.nil?
|
|
125
|
+
event["anonymousId"] = anonymous_id unless anonymous_id.nil?
|
|
126
|
+
event["properties"] = snapshot unless snapshot.nil?
|
|
127
|
+
event["timestamp"] = timestamp
|
|
128
|
+
event["sdkVersion"] = sdk_version unless sdk_version.nil?
|
|
129
|
+
event["insertId"] = insert_id unless insert_id.nil?
|
|
130
|
+
BuildEventResult.new(true, event, nil)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Dedup semantics are corpus-frozen; the eviction strategy under memory pressure is not.
|
|
135
|
+
class ExposureDeduper
|
|
136
|
+
def initialize(max_keys = Core::EXPOSURE_DEDUP_MAX_KEYS)
|
|
137
|
+
@seen = {}
|
|
138
|
+
@max_keys = max_keys
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def should_log?(identity, experiment_key, variant_name, now_ms)
|
|
142
|
+
if @seen.length >= @max_keys
|
|
143
|
+
@seen.delete_if { |_, ts| now_ms - ts >= Core::EXPOSURE_DEDUP_WINDOW_MS }
|
|
144
|
+
@seen.clear if @seen.length >= @max_keys
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
key = "#{identity}:#{experiment_key}:#{variant_name}"
|
|
148
|
+
last = @seen[key]
|
|
149
|
+
return false if !last.nil? && now_ms - last < Core::EXPOSURE_DEDUP_WINDOW_MS
|
|
150
|
+
|
|
151
|
+
@seen[key] = now_ms
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def clear
|
|
156
|
+
@seen.clear
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "core"
|
|
4
|
+
require_relative "transport"
|
|
5
|
+
|
|
6
|
+
module Trevosdk
|
|
7
|
+
# Fetches experiment config on an interval and hands parsed snapshots to the client.
|
|
8
|
+
class ConfigPoller
|
|
9
|
+
def initialize(config_url:, secret_key:, interval_s:, transport:, on_config:, on_error:)
|
|
10
|
+
@config_url = config_url
|
|
11
|
+
@secret_key = secret_key
|
|
12
|
+
@interval_s = interval_s
|
|
13
|
+
@transport = transport
|
|
14
|
+
@on_config = on_config
|
|
15
|
+
@on_error = on_error
|
|
16
|
+
|
|
17
|
+
@etag = nil
|
|
18
|
+
@stop_mutex = Mutex.new
|
|
19
|
+
@stop_cv = ConditionVariable.new
|
|
20
|
+
@stopped = false
|
|
21
|
+
@ready_mutex = Mutex.new
|
|
22
|
+
@ready_cv = ConditionVariable.new
|
|
23
|
+
@ready = false
|
|
24
|
+
@worker = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start
|
|
28
|
+
return unless @worker.nil?
|
|
29
|
+
@worker = Thread.new { run }
|
|
30
|
+
@worker.name = "trevosdk-poller"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def wait_ready(timeout_s = nil)
|
|
34
|
+
@ready_mutex.synchronize do
|
|
35
|
+
@ready_cv.wait(@ready_mutex, timeout_s) unless @ready
|
|
36
|
+
@ready
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop
|
|
41
|
+
@stop_mutex.synchronize do
|
|
42
|
+
@stopped = true
|
|
43
|
+
@stop_cv.signal
|
|
44
|
+
end
|
|
45
|
+
@worker&.join(1)
|
|
46
|
+
@worker = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def run
|
|
52
|
+
until @stop_mutex.synchronize { @stopped }
|
|
53
|
+
begin
|
|
54
|
+
poll
|
|
55
|
+
rescue => error
|
|
56
|
+
@on_error.call(error)
|
|
57
|
+
ensure
|
|
58
|
+
# Ready resolves after the first attempt, success or not, so callers
|
|
59
|
+
# waiting on it are never wedged by a dead config endpoint.
|
|
60
|
+
signal_ready
|
|
61
|
+
end
|
|
62
|
+
@stop_mutex.synchronize { @stop_cv.wait(@stop_mutex, @interval_s) unless @stopped }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def signal_ready
|
|
67
|
+
@ready_mutex.synchronize do
|
|
68
|
+
@ready = true
|
|
69
|
+
@ready_cv.broadcast
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def poll
|
|
74
|
+
headers = Trevosdk.auth_headers(@secret_key)
|
|
75
|
+
headers["If-None-Match"] = @etag unless @etag.nil?
|
|
76
|
+
response = @transport.call(
|
|
77
|
+
TransportRequest.new(method: "GET", url: @config_url, headers: headers)
|
|
78
|
+
)
|
|
79
|
+
return if response.status == 304
|
|
80
|
+
unless (200..299).cover?(response.status)
|
|
81
|
+
raise "Trevo config fetch failed with status #{response.status}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
parsed = Core.parse_config_response(response.json)
|
|
85
|
+
raise "Trevo config response invalid: #{parsed.error}" unless parsed.success
|
|
86
|
+
|
|
87
|
+
etag = (response.headers || {}).transform_keys(&:downcase)["etag"]
|
|
88
|
+
@etag = etag unless etag.nil? || etag.empty?
|
|
89
|
+
@on_config.call(parsed.experiments)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Trevosdk
|
|
8
|
+
CONFIG_URL = "https://api.trevosdk.com/v1/config"
|
|
9
|
+
INGESTION_URL = "https://ingest.trevosdk.com/v1/events"
|
|
10
|
+
ALIAS_URL = "https://ingest.trevosdk.com/v1/alias"
|
|
11
|
+
|
|
12
|
+
DEFAULT_POLL_INTERVAL_S = 60.0
|
|
13
|
+
MIN_POLL_INTERVAL_S = 10.0
|
|
14
|
+
DEFAULT_FLUSH_INTERVAL_S = 5.0
|
|
15
|
+
MIN_FLUSH_INTERVAL_S = 0.5
|
|
16
|
+
DEFAULT_MAX_BATCH_SIZE = 50
|
|
17
|
+
SERVER_MAX_BATCH_SIZE = 500
|
|
18
|
+
MAX_QUEUE_SIZE = 10_000
|
|
19
|
+
REQUEST_TIMEOUT_S = 10.0
|
|
20
|
+
SERVER_EXPOSURE_DEDUP_MAX_KEYS = 100_000
|
|
21
|
+
FORCE_VARIANTS_ENV = "TREVO_FORCE_VARIANTS"
|
|
22
|
+
|
|
23
|
+
# No custom initialize: Ruby 3.2 structs accept keywords only through the default one.
|
|
24
|
+
TransportRequest = Struct.new(:method, :url, :headers, :body, :timeout_s)
|
|
25
|
+
|
|
26
|
+
TransportResponse = Struct.new(:status, :body, :headers) do
|
|
27
|
+
def json
|
|
28
|
+
JSON.parse(body.to_s)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
DEFAULT_TRANSPORT = lambda do |request|
|
|
33
|
+
uri = URI.parse(request.url)
|
|
34
|
+
timeout = request.timeout_s || REQUEST_TIMEOUT_S
|
|
35
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
36
|
+
http.use_ssl = uri.scheme == "https"
|
|
37
|
+
http.open_timeout = timeout
|
|
38
|
+
http.read_timeout = timeout
|
|
39
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
40
|
+
|
|
41
|
+
response = http.send_request(request.method, uri.request_uri, request.body, request.headers)
|
|
42
|
+
TransportResponse.new(
|
|
43
|
+
status: response.code.to_i,
|
|
44
|
+
body: response.body.to_s,
|
|
45
|
+
headers: response.each_header.to_h
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def auth_headers(secret_key)
|
|
52
|
+
{"Content-Type" => "application/json", "Authorization" => "Bearer #{secret_key}"}
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/trevosdk.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "trevosdk/client"
|
|
4
|
+
require_relative "trevosdk/core"
|
|
5
|
+
require_relative "trevosdk/version"
|
|
6
|
+
|
|
7
|
+
module Trevosdk
|
|
8
|
+
class << self
|
|
9
|
+
# Process-wide singleton for the common one-client-per-app setup
|
|
10
|
+
# (e.g. a Rails config/initializers/trevosdk.rb).
|
|
11
|
+
def init(secret_key, **options)
|
|
12
|
+
@client&.close
|
|
13
|
+
@client = Client.new(secret_key, **options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def client
|
|
17
|
+
@client || raise("Trevosdk.init has not been called")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def close
|
|
21
|
+
@client&.close
|
|
22
|
+
@client = nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: trevosdk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Trevo
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-17 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- README.md
|
|
20
|
+
- lib/trevosdk.rb
|
|
21
|
+
- lib/trevosdk/batcher.rb
|
|
22
|
+
- lib/trevosdk/client.rb
|
|
23
|
+
- lib/trevosdk/core.rb
|
|
24
|
+
- lib/trevosdk/poller.rb
|
|
25
|
+
- lib/trevosdk/transport.rb
|
|
26
|
+
- lib/trevosdk/version.rb
|
|
27
|
+
homepage: https://trevosdk.com
|
|
28
|
+
licenses:
|
|
29
|
+
- MIT
|
|
30
|
+
metadata:
|
|
31
|
+
homepage_uri: https://trevosdk.com
|
|
32
|
+
documentation_uri: https://docs.trevosdk.com
|
|
33
|
+
rubygems_mfa_required: 'true'
|
|
34
|
+
post_install_message:
|
|
35
|
+
rdoc_options: []
|
|
36
|
+
require_paths:
|
|
37
|
+
- lib
|
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '3.2'
|
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
requirements: []
|
|
49
|
+
rubygems_version: 3.0.3.1
|
|
50
|
+
signing_key:
|
|
51
|
+
specification_version: 4
|
|
52
|
+
summary: Trevo server SDK for Ruby — deterministic variant assignment and event tracking
|
|
53
|
+
test_files: []
|