trevosdk 0.1.0 → 0.1.2
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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +2 -0
- data/lib/trevosdk/batcher.rb +176 -38
- data/lib/trevosdk/client.rb +34 -9
- data/lib/trevosdk/core.rb +60 -16
- data/lib/trevosdk/errors.rb +34 -0
- data/lib/trevosdk/poller.rb +37 -5
- data/lib/trevosdk/transport.rb +5 -0
- data/lib/trevosdk/version.rb +1 -1
- data/lib/trevosdk.rb +10 -2
- metadata +5 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c05a483b9ac99c124d83cf4eea80cedbac818112aea0a1187850355180bb7a0b
|
|
4
|
+
data.tar.gz: 9fefd4fd2d6670e124506c03304c1045934e62859b9aacfc6f1d57c5dc93458e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5fbad477a55bddba3bb530d2fc59a566f5723df1f858316ead1875c611fc389e9dad4bd25558fac373e1887fca35bcc5f9e8118255940795504f724f96686b9d
|
|
7
|
+
data.tar.gz: 68e0911e14e8df2274f20cf365049823d00444221a666564eb39c08d8a8dbd99992017cd74354deb422f2398906cdbe3ef011ca56763ce906c780ea5b99b8eb0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Ruby **trevosdk** gem are documented here.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.2] - 2026-09-23
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Releases use the current official RubyGems trusted-publishing action.
|
|
12
|
+
|
|
13
|
+
## [0.1.1] - 2026-09-22
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Public error classes for configuration, delivery, serialization,
|
|
18
|
+
bounded-queue, and rejected-event failures.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Forked Puma and job-worker processes restart background delivery instead of
|
|
23
|
+
inheriting unusable parent threads.
|
|
24
|
+
- Delivery backs off during sustained outages, reapplies queue bounds after
|
|
25
|
+
retries, and keeps shutdown bounded without losing concurrently queued
|
|
26
|
+
events.
|
|
27
|
+
- Rails-style integer user IDs and symbol experiment keys are normalized;
|
|
28
|
+
queued strings are copied so caller mutation cannot rewrite an event.
|
|
29
|
+
- Payload byte limits, whitespace validation, self-hosted alias URLs, exposure
|
|
30
|
+
deduplication, and config ETag handling now match the cross-SDK contract.
|
|
31
|
+
|
|
32
|
+
## [0.1.0] - 2026-08-16
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- Initial release with deterministic server assignment, configuration polling,
|
|
37
|
+
event batching, identity linking, and shared conformance vectors.
|
data/README.md
CHANGED
|
@@ -11,6 +11,8 @@ gem install trevosdk
|
|
|
11
11
|
# config/initializers/trevosdk.rb (Rails) or anywhere at boot
|
|
12
12
|
Trevosdk.init(ENV.fetch("TREVO_SECRET_KEY"))
|
|
13
13
|
|
|
14
|
+
# Bound the first config wait; later background polls keep recovering.
|
|
15
|
+
Trevosdk.client.ready(2)
|
|
14
16
|
variant = Trevosdk.client.get_variant("checkout-cta", user_id: current_user.id)
|
|
15
17
|
if variant == "treatment"
|
|
16
18
|
# alternate experience
|
data/lib/trevosdk/batcher.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
5
|
|
|
6
|
+
require_relative "errors"
|
|
6
7
|
require_relative "transport"
|
|
7
8
|
|
|
8
9
|
module Trevosdk
|
|
@@ -22,8 +23,30 @@ module Trevosdk
|
|
|
22
23
|
@wake_mutex = Mutex.new
|
|
23
24
|
@wake_cv = ConditionVariable.new
|
|
24
25
|
@wake_flag = false
|
|
26
|
+
@stop_mutex = Mutex.new
|
|
27
|
+
@stop_cv = ConditionVariable.new
|
|
25
28
|
@stopped = false
|
|
29
|
+
@draining = false
|
|
26
30
|
@dropped = 0
|
|
31
|
+
@rejected_after_close = 0
|
|
32
|
+
@consecutive_failures = 0
|
|
33
|
+
@worker = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Rebuilds every lock and clears the dead worker handle inherited from the
|
|
37
|
+
# parent. Threads do not survive a fork, but the started flag did.
|
|
38
|
+
def reset_after_fork
|
|
39
|
+
@mutex = Mutex.new
|
|
40
|
+
@send_mutex = Mutex.new
|
|
41
|
+
@wake_mutex = Mutex.new
|
|
42
|
+
@wake_cv = ConditionVariable.new
|
|
43
|
+
@wake_flag = false
|
|
44
|
+
@stop_mutex = Mutex.new
|
|
45
|
+
@stop_cv = ConditionVariable.new
|
|
46
|
+
@stopped = false
|
|
47
|
+
@draining = false
|
|
48
|
+
@rejected_after_close = 0
|
|
49
|
+
@consecutive_failures = 0
|
|
27
50
|
@worker = nil
|
|
28
51
|
end
|
|
29
52
|
|
|
@@ -36,43 +59,117 @@ module Trevosdk
|
|
|
36
59
|
def enqueue(event)
|
|
37
60
|
first_drop = false
|
|
38
61
|
over_batch = false
|
|
62
|
+
rejected = false
|
|
39
63
|
@mutex.synchronize do
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
if @stopped
|
|
65
|
+
@rejected_after_close += 1
|
|
66
|
+
rejected = @rejected_after_close == 1
|
|
67
|
+
elsif @queue.length >= MAX_QUEUE_SIZE
|
|
42
68
|
# Drop oldest: an outage should cost the stalest events, not the newest.
|
|
43
69
|
@queue.shift
|
|
44
70
|
@dropped += 1
|
|
45
71
|
first_drop = @dropped == 1
|
|
46
72
|
end
|
|
47
|
-
@
|
|
48
|
-
|
|
73
|
+
unless @stopped
|
|
74
|
+
@queue.push(event)
|
|
75
|
+
over_batch = @queue.length >= @batch_size
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
if rejected
|
|
79
|
+
@on_error.call(QueueFullError.new("Trevo client is closed; events tracked after close are dropped"))
|
|
80
|
+
return
|
|
49
81
|
end
|
|
50
82
|
if first_drop
|
|
51
|
-
@on_error.call(
|
|
83
|
+
@on_error.call(QueueFullError.new("Trevo event queue full at #{MAX_QUEUE_SIZE}; dropping oldest events"))
|
|
52
84
|
end
|
|
53
85
|
wake! if over_batch
|
|
54
86
|
end
|
|
55
87
|
|
|
56
88
|
# Sends everything queued; raises on delivery failure so callers get a real guarantee.
|
|
57
|
-
def flush
|
|
58
|
-
@send_mutex.synchronize { drain }
|
|
89
|
+
def flush(deadline = nil)
|
|
90
|
+
@send_mutex.synchronize { drain(deadline) }
|
|
59
91
|
end
|
|
60
92
|
|
|
61
93
|
# Stops the worker and flushes once; never raises — this runs from exit hooks.
|
|
62
94
|
def shutdown
|
|
63
|
-
|
|
95
|
+
signal_stop
|
|
64
96
|
wake!
|
|
65
97
|
@worker&.join(@flush_interval_s + 1)
|
|
66
98
|
@worker = nil
|
|
67
99
|
begin
|
|
68
|
-
|
|
100
|
+
# Bounded. An abandoned worker can hold the send mutex across a whole
|
|
101
|
+
# multi-request drain, and close runs on the process-exit path, where a
|
|
102
|
+
# container's SIGTERM grace period is seconds, not minutes.
|
|
103
|
+
flush_bounded(SHUTDOWN_FLUSH_TIMEOUT_S)
|
|
69
104
|
rescue => error
|
|
70
105
|
@on_error.call(error)
|
|
106
|
+
ensure
|
|
107
|
+
# Set after the flush, so an event tracked while a SIGTERM handler runs
|
|
108
|
+
# is still caught by the flush it races.
|
|
109
|
+
@mutex.synchronize { @stopped = true }
|
|
71
110
|
end
|
|
72
111
|
end
|
|
73
112
|
|
|
74
113
|
private
|
|
75
114
|
|
|
115
|
+
def flush_bounded(timeout_s)
|
|
116
|
+
deadline = monotonic + timeout_s
|
|
117
|
+
unless try_lock_until(@send_mutex, deadline)
|
|
118
|
+
@on_error.call(
|
|
119
|
+
DeliveryError.new(
|
|
120
|
+
"Trevo shutdown timed out waiting for an in-flight flush; queued events were left undelivered"
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
begin
|
|
126
|
+
drain(deadline)
|
|
127
|
+
ensure
|
|
128
|
+
@send_mutex.unlock
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def try_lock_until(mutex, deadline)
|
|
133
|
+
until mutex.try_lock
|
|
134
|
+
return false if monotonic >= deadline
|
|
135
|
+
sleep(0.01)
|
|
136
|
+
end
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def monotonic
|
|
141
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def backoff_delay
|
|
145
|
+
base = [@flush_interval_s * (2**@consecutive_failures), MAX_FLUSH_BACKOFF_S].min
|
|
146
|
+
# ±20% jitter stops every process in a fleet retrying in lockstep.
|
|
147
|
+
base * (0.8 + (rand * 0.4))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def signal_stop
|
|
151
|
+
@stop_mutex.synchronize do
|
|
152
|
+
@draining = true
|
|
153
|
+
@stop_cv.broadcast
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def draining?
|
|
158
|
+
@stop_mutex.synchronize { @draining }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Sleeps up to timeout_s, returning early only when shutdown signals.
|
|
162
|
+
def wait_for_stop(timeout_s)
|
|
163
|
+
deadline = monotonic + timeout_s
|
|
164
|
+
@stop_mutex.synchronize do
|
|
165
|
+
until @draining
|
|
166
|
+
remaining = deadline - monotonic
|
|
167
|
+
break if remaining <= 0
|
|
168
|
+
@stop_cv.wait(@stop_mutex, remaining)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
76
173
|
def wake!
|
|
77
174
|
@wake_mutex.synchronize do
|
|
78
175
|
@wake_flag = true
|
|
@@ -82,61 +179,102 @@ module Trevosdk
|
|
|
82
179
|
|
|
83
180
|
def run
|
|
84
181
|
loop do
|
|
85
|
-
@
|
|
86
|
-
|
|
87
|
-
|
|
182
|
+
if @consecutive_failures.positive?
|
|
183
|
+
# Backs off on a timer the enqueue signal cannot short-circuit, or a
|
|
184
|
+
# partial ingestion outage becomes a fleet-wide retry storm. Only
|
|
185
|
+
# shutdown breaks it, so close does not block for the whole delay.
|
|
186
|
+
wait_for_stop(backoff_delay)
|
|
187
|
+
@wake_mutex.synchronize { @wake_flag = false }
|
|
188
|
+
else
|
|
189
|
+
@wake_mutex.synchronize do
|
|
190
|
+
@wake_cv.wait(@wake_mutex, @flush_interval_s) unless @wake_flag
|
|
191
|
+
@wake_flag = false
|
|
192
|
+
end
|
|
88
193
|
end
|
|
89
|
-
return if
|
|
194
|
+
return if draining?
|
|
90
195
|
begin
|
|
91
196
|
flush
|
|
197
|
+
@consecutive_failures = 0
|
|
92
198
|
rescue => error
|
|
199
|
+
@consecutive_failures += 1
|
|
93
200
|
@on_error.call(error)
|
|
94
201
|
end
|
|
95
202
|
end
|
|
96
203
|
end
|
|
97
204
|
|
|
98
|
-
def drain
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
205
|
+
def drain(deadline = nil)
|
|
206
|
+
loop do
|
|
207
|
+
return if !deadline.nil? && monotonic >= deadline
|
|
208
|
+
|
|
209
|
+
# One batch at a time: snapshotting the whole queue meant any exception
|
|
210
|
+
# outside the per-batch guard destroyed all of it.
|
|
211
|
+
batch = @mutex.synchronize { @queue.shift(@batch_size) }
|
|
212
|
+
return if batch.empty?
|
|
213
|
+
|
|
214
|
+
begin
|
|
215
|
+
body = serialize(batch)
|
|
216
|
+
rescue => error
|
|
217
|
+
# Deterministic: requeuing would block the queue forever.
|
|
218
|
+
@on_error.call(
|
|
219
|
+
SerializationError.new(
|
|
220
|
+
"Trevo dropped #{batch.length} event(s): could not serialize them (#{error.message})"
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
next
|
|
224
|
+
end
|
|
104
225
|
|
|
105
|
-
pending.each_slice(@batch_size).with_index do |batch, index|
|
|
106
226
|
status = begin
|
|
107
|
-
|
|
227
|
+
post(body)
|
|
108
228
|
rescue
|
|
109
229
|
nil
|
|
110
230
|
end
|
|
111
231
|
next if !status.nil? && (200..299).cover?(status)
|
|
112
232
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"Trevo dropped #{batch.length} event(s): ingestion rejected them with status #{status.inspect}"
|
|
122
|
-
)
|
|
233
|
+
if retryable?(status)
|
|
234
|
+
requeue(batch)
|
|
235
|
+
else
|
|
236
|
+
# A rejected batch will be rejected identically forever; drop it, keep the rest.
|
|
237
|
+
@on_error.call(
|
|
238
|
+
DeliveryError.new(
|
|
239
|
+
"Trevo dropped #{batch.length} event(s): ingestion rejected them with status #{status.inspect}",
|
|
240
|
+
status
|
|
123
241
|
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
raise "Trevo event delivery failed with status #{status.inspect}"
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
raise DeliveryError.new("Trevo event delivery failed with status #{status.inspect}", status)
|
|
128
245
|
end
|
|
129
246
|
end
|
|
130
247
|
|
|
248
|
+
def requeue(batch)
|
|
249
|
+
dropped = 0
|
|
250
|
+
@mutex.synchronize do
|
|
251
|
+
@queue.unshift(*batch)
|
|
252
|
+
if @queue.length > MAX_QUEUE_SIZE
|
|
253
|
+
# Re-apply the bound: enqueue admits another full MAX_QUEUE_SIZE
|
|
254
|
+
# while a flush is in flight.
|
|
255
|
+
dropped = @queue.length - MAX_QUEUE_SIZE
|
|
256
|
+
@queue = @queue[0, MAX_QUEUE_SIZE]
|
|
257
|
+
@dropped += dropped
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
return if dropped.zero?
|
|
261
|
+
@on_error.call(
|
|
262
|
+
QueueFullError.new("Trevo event queue full at #{MAX_QUEUE_SIZE}; dropped #{dropped} event(s)")
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
131
266
|
def retryable?(status)
|
|
132
267
|
return true if status.nil?
|
|
133
268
|
return true if [408, 429].include?(status)
|
|
134
269
|
status >= 500
|
|
135
270
|
end
|
|
136
271
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
272
|
+
# sentAt is stamped at send, so a batch re-queued through an outage stays correctable.
|
|
273
|
+
def serialize(events)
|
|
274
|
+
JSON.generate({events: events, sentAt: Time.now.utc.iso8601(3)})
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def post(body)
|
|
140
278
|
response = @transport.call(
|
|
141
279
|
TransportRequest.new(
|
|
142
280
|
method: "POST",
|
data/lib/trevosdk/client.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "time"
|
|
|
5
5
|
|
|
6
6
|
require_relative "batcher"
|
|
7
7
|
require_relative "core"
|
|
8
|
+
require_relative "errors"
|
|
8
9
|
require_relative "poller"
|
|
9
10
|
require_relative "transport"
|
|
10
11
|
require_relative "version"
|
|
@@ -34,7 +35,7 @@ module Trevosdk
|
|
|
34
35
|
@on_error = on_error || ->(error) { warn("[Trevo] #{error.message}") }
|
|
35
36
|
@alias_url = alias_url || self.class.derive_alias_url(ingestion_url)
|
|
36
37
|
|
|
37
|
-
@experiments = (bootstrap_config || []).to_h { |config| [config["experimentKey"], config] }
|
|
38
|
+
@experiments = (bootstrap_config || []).to_h { |config| [config["experimentKey"].to_s, config] }
|
|
38
39
|
@exposures = ExposureDeduper.new(SERVER_EXPOSURE_DEDUP_MAX_KEYS)
|
|
39
40
|
@exposures_mutex = Mutex.new
|
|
40
41
|
@forced = self.class.parse_force_variants(ENV[FORCE_VARIANTS_ENV])
|
|
@@ -60,14 +61,17 @@ module Trevosdk
|
|
|
60
61
|
@start_mutex = Mutex.new
|
|
61
62
|
@started = false
|
|
62
63
|
@closed = false
|
|
64
|
+
@forked_pid = Process.pid
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
# Alias lives on the same service as events, so an ingestion override must move alias too.
|
|
68
|
+
#
|
|
69
|
+
# A substitution, never a fallback to the public endpoint: sending a
|
|
70
|
+
# self-hosted deployment's alias traffic to trevosdk.com would ship the
|
|
71
|
+
# customer's secret key and identity graph off their own host.
|
|
66
72
|
def self.derive_alias_url(ingestion_url)
|
|
67
73
|
return ALIAS_URL if ingestion_url == INGESTION_URL
|
|
68
|
-
|
|
69
|
-
return "#{trimmed.delete_suffix("/events")}/alias" if trimmed.end_with?("/events")
|
|
70
|
-
ALIAS_URL
|
|
74
|
+
ingestion_url.sub(%r{/events/?\z}, "/alias")
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
def self.parse_force_variants(raw)
|
|
@@ -82,7 +86,10 @@ module Trevosdk
|
|
|
82
86
|
end
|
|
83
87
|
|
|
84
88
|
def get_variant(experiment_key, user_id: nil, anonymous_id: nil, track_exposure: true)
|
|
85
|
-
|
|
89
|
+
# Normalised once, then used for every lookup: guarding with .to_s while
|
|
90
|
+
# looking the key up raw put a Symbol key in control, silently.
|
|
91
|
+
experiment_key = experiment_key.to_s
|
|
92
|
+
return Core::CONTROL_VARIANT if experiment_key.empty?
|
|
86
93
|
ensure_started
|
|
87
94
|
|
|
88
95
|
config = @experiments[experiment_key]
|
|
@@ -136,9 +143,14 @@ module Trevosdk
|
|
|
136
143
|
)
|
|
137
144
|
)
|
|
138
145
|
if response.status == 409
|
|
139
|
-
raise
|
|
146
|
+
raise DeliveryError.new(
|
|
147
|
+
"Trevo alias rejected: anonymousId \"#{anonymous_id}\" is already linked to a different user",
|
|
148
|
+
409
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
unless (200..299).cover?(response.status)
|
|
152
|
+
raise DeliveryError.new("Trevo alias failed with status #{response.status}", response.status)
|
|
140
153
|
end
|
|
141
|
-
raise "Trevo alias failed with status #{response.status}" unless (200..299).cover?(response.status)
|
|
142
154
|
nil
|
|
143
155
|
end
|
|
144
156
|
|
|
@@ -161,10 +173,11 @@ module Trevosdk
|
|
|
161
173
|
private
|
|
162
174
|
|
|
163
175
|
def swap_config(configs)
|
|
164
|
-
@experiments = configs.to_h { |config| [config["experimentKey"], config] }
|
|
176
|
+
@experiments = configs.to_h { |config| [config["experimentKey"].to_s, config] }
|
|
165
177
|
end
|
|
166
178
|
|
|
167
179
|
def ensure_started
|
|
180
|
+
restart_after_fork if @forked_pid != Process.pid
|
|
168
181
|
return if @started
|
|
169
182
|
@start_mutex.synchronize do
|
|
170
183
|
return if @started || @closed
|
|
@@ -175,6 +188,18 @@ module Trevosdk
|
|
|
175
188
|
end
|
|
176
189
|
end
|
|
177
190
|
|
|
191
|
+
# Puma clustered, Unicorn and Sidekiq fork after a Rails initializer builds
|
|
192
|
+
# the client. Threads do not survive a fork but @started did, so the child
|
|
193
|
+
# delivered nothing at all.
|
|
194
|
+
def restart_after_fork
|
|
195
|
+
@forked_pid = Process.pid
|
|
196
|
+
@start_mutex = Mutex.new
|
|
197
|
+
@exposures_mutex = Mutex.new
|
|
198
|
+
@batcher.reset_after_fork
|
|
199
|
+
@poller.reset_after_fork
|
|
200
|
+
@started = false
|
|
201
|
+
end
|
|
202
|
+
|
|
178
203
|
def enqueue_event(event_name, user_id, anonymous_id, properties, insert_id)
|
|
179
204
|
built = Core.build_event(
|
|
180
205
|
event_name,
|
|
@@ -187,7 +212,7 @@ module Trevosdk
|
|
|
187
212
|
insert_id: insert_id || SecureRandom.uuid
|
|
188
213
|
)
|
|
189
214
|
unless built.ok
|
|
190
|
-
@on_error.call(
|
|
215
|
+
@on_error.call(EventRejectedError.new("Trevo track(\"#{event_name}\") ignored: #{built.reason}"))
|
|
191
216
|
return
|
|
192
217
|
end
|
|
193
218
|
|
data/lib/trevosdk/core.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
5
7
|
module Trevosdk
|
|
6
8
|
# Pure evaluation core — no I/O, no clock reads; time is a parameter.
|
|
7
9
|
# Frozen contract: must match packages/contracts/src/corpus/vectors.json exactly.
|
|
@@ -17,6 +19,14 @@ module Trevosdk
|
|
|
17
19
|
EXPOSURE_DEDUP_WINDOW_MS = 600_000
|
|
18
20
|
EXPOSURE_DEDUP_MAX_KEYS = 1_000
|
|
19
21
|
|
|
22
|
+
# The exact set JS String.prototype.trim() strips (ECMA-262 WhiteSpace +
|
|
23
|
+
# LineTerminator). Ruby's String#strip and Python's str.strip() each cover a
|
|
24
|
+
# different set, so the frozen contract returned a different rejection
|
|
25
|
+
# reason per language for the same input.
|
|
26
|
+
JS_WHITESPACE = "\u0009\u000A\u000B\u000C\u000D\u0020\u00A0\u1680" \
|
|
27
|
+
"\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A" \
|
|
28
|
+
"\u2028\u2029\u202F\u205F\u3000\uFEFF"
|
|
29
|
+
|
|
20
30
|
FNV_OFFSET_BASIS_32 = 0x811c9dc5
|
|
21
31
|
FNV_PRIME_32 = 0x01000193
|
|
22
32
|
MASK_32 = 0xffffffff
|
|
@@ -26,6 +36,11 @@ module Trevosdk
|
|
|
26
36
|
|
|
27
37
|
module_function
|
|
28
38
|
|
|
39
|
+
# True when the string is empty after stripping exactly what JS trims.
|
|
40
|
+
def js_blank?(value)
|
|
41
|
+
value.delete(JS_WHITESPACE).empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
29
44
|
# FNV-1a over UTF-16 code units (not bytes) — matches the reference JS SDK.
|
|
30
45
|
# Invalid bytes hash as U+FFFD instead of raising: such strings have no JS
|
|
31
46
|
# equivalent, so deterministic assignment beats crashing the request.
|
|
@@ -61,8 +76,11 @@ module Trevosdk
|
|
|
61
76
|
last_variant_name = nil
|
|
62
77
|
variants.each do |variant|
|
|
63
78
|
cumulative += variant["trafficSplit"] * 100
|
|
64
|
-
|
|
65
|
-
|
|
79
|
+
# A copy: returning the config's own String let a caller who mutates the
|
|
80
|
+
# result rewrite the shared experiment snapshot for every later request
|
|
81
|
+
# on the process.
|
|
82
|
+
return variant["name"].to_s.dup if bucket < cumulative
|
|
83
|
+
last_variant_name = variant["name"].to_s.dup
|
|
66
84
|
end
|
|
67
85
|
|
|
68
86
|
last_variant_name || CONTROL_VARIANT
|
|
@@ -96,9 +114,21 @@ module Trevosdk
|
|
|
96
114
|
ParseResult.new(true, experiments, nil)
|
|
97
115
|
end
|
|
98
116
|
|
|
117
|
+
# Frozen copy of a caller-owned String, so later mutation cannot reach a
|
|
118
|
+
# queued event. Non-strings (an Integer user id) pass through untouched.
|
|
119
|
+
#
|
|
120
|
+
# Invalid bytes are folded to U+FFFD exactly as fnv1a32 does. Rack and
|
|
121
|
+
# ActiveRecord hand you such strings routinely, and tolerating them at the
|
|
122
|
+
# hashing layer only to choke on them at JSON serialization is what let one
|
|
123
|
+
# event wedge the whole delivery queue.
|
|
124
|
+
def snapshot_string(value)
|
|
125
|
+
return value unless value.is_a?(String)
|
|
126
|
+
value.valid_encoding? ? value.dup : value.scrub
|
|
127
|
+
end
|
|
128
|
+
|
|
99
129
|
def build_event(event_name, timestamp:, user_id: nil, anonymous_id: nil,
|
|
100
130
|
properties: nil, sdk_version: nil, insert_id: nil)
|
|
101
|
-
unless event_name.is_a?(String) && !event_name
|
|
131
|
+
unless event_name.is_a?(String) && !js_blank?(event_name)
|
|
102
132
|
return BuildEventResult.new(false, nil, "empty-name")
|
|
103
133
|
end
|
|
104
134
|
return BuildEventResult.new(false, nil, "name-too-long") if event_name.length > MAX_EVENT_NAME_LENGTH
|
|
@@ -111,22 +141,35 @@ module Trevosdk
|
|
|
111
141
|
rescue JSON::NestingError, JSON::GeneratorError, SystemStackError
|
|
112
142
|
return BuildEventResult.new(false, nil, "properties-unserializable")
|
|
113
143
|
end
|
|
114
|
-
|
|
144
|
+
# Measured in UTF-8 BYTES, as the server does. #length counts
|
|
145
|
+
# characters, so an emoji or CJK payload was accepted at up to 4x the
|
|
146
|
+
# intended budget and ingestion then 400'd — dropping the whole batch.
|
|
147
|
+
if serialized.bytesize > MAX_PROPERTIES_BYTES
|
|
148
|
+
return BuildEventResult.new(false, nil, "properties-too-large")
|
|
149
|
+
end
|
|
115
150
|
# Round trip both validates and deep-copies, isolating the caller from later mutation.
|
|
116
151
|
snapshot = JSON.parse(serialized)
|
|
117
152
|
end
|
|
118
153
|
|
|
119
|
-
|
|
154
|
+
# `.to_s.empty?`, matching Client#get_variant and the JS reference's
|
|
155
|
+
# truthiness check: `current_user.id` is an Integer in every Rails app,
|
|
156
|
+
# and it is the idiom the README recommends.
|
|
157
|
+
if user_id.to_s.empty? && anonymous_id.to_s.empty?
|
|
120
158
|
return BuildEventResult.new(false, nil, "no-identity")
|
|
121
159
|
end
|
|
122
160
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
# Every caller-supplied String is snapshotted, not just properties. A
|
|
162
|
+
# reused buffer (`buf.replace(id)`, the standard allocation-avoidance
|
|
163
|
+
# idiom) otherwise rewrote events already queued, putting one user's id on
|
|
164
|
+
# rows belonging to another — undetectable server-side, because the
|
|
165
|
+
# payload is internally inconsistent rather than malformed.
|
|
166
|
+
event = {"event" => snapshot_string(event_name)}
|
|
167
|
+
event["userId"] = snapshot_string(user_id) unless user_id.nil?
|
|
168
|
+
event["anonymousId"] = snapshot_string(anonymous_id) unless anonymous_id.nil?
|
|
126
169
|
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?
|
|
170
|
+
event["timestamp"] = snapshot_string(timestamp)
|
|
171
|
+
event["sdkVersion"] = snapshot_string(sdk_version) unless sdk_version.nil?
|
|
172
|
+
event["insertId"] = snapshot_string(insert_id) unless insert_id.nil?
|
|
130
173
|
BuildEventResult.new(true, event, nil)
|
|
131
174
|
end
|
|
132
175
|
end
|
|
@@ -139,16 +182,17 @@ module Trevosdk
|
|
|
139
182
|
end
|
|
140
183
|
|
|
141
184
|
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
185
|
key = "#{identity}:#{experiment_key}:#{variant_name}"
|
|
148
186
|
last = @seen[key]
|
|
149
187
|
return false if !last.nil? && now_ms - last < Core::EXPOSURE_DEDUP_WINDOW_MS
|
|
150
188
|
|
|
189
|
+
# Ruby hashes are insertion-ordered, so eviction is shifting the oldest
|
|
190
|
+
# entry rather than scanning every key. The previous delete_if ran on
|
|
191
|
+
# EVERY call once the map reached its cap, in a Ruby block that never
|
|
192
|
+
# releases the GVL — freezing every thread in the worker for ~4ms.
|
|
193
|
+
@seen.delete(key)
|
|
151
194
|
@seen[key] = now_ms
|
|
195
|
+
@seen.shift while @seen.length > @max_keys
|
|
152
196
|
true
|
|
153
197
|
end
|
|
154
198
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevosdk
|
|
4
|
+
# Every failure this SDK raises or reports through +on_error+ is one of these.
|
|
5
|
+
# A bare RuntimeError forces callers to string-match the message to tell
|
|
6
|
+
# "queue full" from "delivery failed" from "event rejected", so a caller who
|
|
7
|
+
# wires up +on_error+ correctly still cannot branch on what happened.
|
|
8
|
+
#
|
|
9
|
+
# Descends from RuntimeError so callers of already published versions, who can
|
|
10
|
+
# only rescue RuntimeError today, keep working.
|
|
11
|
+
class Error < RuntimeError; end
|
|
12
|
+
|
|
13
|
+
# Config could not be fetched, or did not match the contract.
|
|
14
|
+
class ConfigError < Error; end
|
|
15
|
+
|
|
16
|
+
# Ingestion rejected a batch or could not be reached.
|
|
17
|
+
class DeliveryError < Error
|
|
18
|
+
attr_reader :status
|
|
19
|
+
|
|
20
|
+
def initialize(message, status = nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@status = status
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A batch could not be serialized. Deterministic — retrying cannot help.
|
|
27
|
+
class SerializationError < Error; end
|
|
28
|
+
|
|
29
|
+
# The bounded queue dropped events.
|
|
30
|
+
class QueueFullError < Error; end
|
|
31
|
+
|
|
32
|
+
# A single event failed validation and was not queued.
|
|
33
|
+
class EventRejectedError < Error; end
|
|
34
|
+
end
|
data/lib/trevosdk/poller.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "core"
|
|
4
|
+
require_relative "errors"
|
|
4
5
|
require_relative "transport"
|
|
5
6
|
|
|
6
7
|
module Trevosdk
|
|
@@ -31,8 +32,20 @@ module Trevosdk
|
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def wait_ready(timeout_s = nil)
|
|
35
|
+
deadline = timeout_s.nil? ? nil : Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
|
|
34
36
|
@ready_mutex.synchronize do
|
|
35
|
-
|
|
37
|
+
# Loop, not a single wait: a spurious wakeup would otherwise return
|
|
38
|
+
# false while the first poll was still in flight.
|
|
39
|
+
until @ready
|
|
40
|
+
remaining =
|
|
41
|
+
if deadline.nil?
|
|
42
|
+
nil
|
|
43
|
+
else
|
|
44
|
+
deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
45
|
+
end
|
|
46
|
+
break if !remaining.nil? && remaining <= 0
|
|
47
|
+
@ready_cv.wait(@ready_mutex, remaining)
|
|
48
|
+
end
|
|
36
49
|
@ready
|
|
37
50
|
end
|
|
38
51
|
end
|
|
@@ -42,7 +55,22 @@ module Trevosdk
|
|
|
42
55
|
@stopped = true
|
|
43
56
|
@stop_cv.signal
|
|
44
57
|
end
|
|
45
|
-
@worker
|
|
58
|
+
worker = @worker
|
|
59
|
+
return if worker.nil?
|
|
60
|
+
# The request timeout plus a margin, matching the batcher's convention. A
|
|
61
|
+
# 1s join against a 10s request left the thread running: it then rewrote
|
|
62
|
+
# the config of a client the application considers dead.
|
|
63
|
+
@worker = nil if worker.join(REQUEST_TIMEOUT_S + 1)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Clears the dead worker handle and sync primitives inherited from a fork.
|
|
67
|
+
def reset_after_fork
|
|
68
|
+
@stop_mutex = Mutex.new
|
|
69
|
+
@stop_cv = ConditionVariable.new
|
|
70
|
+
@stopped = false
|
|
71
|
+
@ready_mutex = Mutex.new
|
|
72
|
+
@ready_cv = ConditionVariable.new
|
|
73
|
+
@ready = false
|
|
46
74
|
@worker = nil
|
|
47
75
|
end
|
|
48
76
|
|
|
@@ -78,14 +106,18 @@ module Trevosdk
|
|
|
78
106
|
)
|
|
79
107
|
return if response.status == 304
|
|
80
108
|
unless (200..299).cover?(response.status)
|
|
81
|
-
raise "Trevo config fetch failed with status #{response.status}"
|
|
109
|
+
raise ConfigError, "Trevo config fetch failed with status #{response.status}"
|
|
82
110
|
end
|
|
83
111
|
|
|
84
112
|
parsed = Core.parse_config_response(response.json)
|
|
85
|
-
raise "Trevo config response invalid: #{parsed.error}" unless parsed.success
|
|
113
|
+
raise ConfigError, "Trevo config response invalid: #{parsed.error}" unless parsed.success
|
|
86
114
|
|
|
115
|
+
# Assigned unconditionally, and only once the payload has validated: a
|
|
116
|
+
# server or CDN that stops sending ETags must clear the stored one, or the
|
|
117
|
+
# client keeps revalidating against a token it will never refresh past —
|
|
118
|
+
# so an experiment change silently never takes effect.
|
|
87
119
|
etag = (response.headers || {}).transform_keys(&:downcase)["etag"]
|
|
88
|
-
@etag = etag
|
|
120
|
+
@etag = (etag.nil? || etag.empty?) ? nil : etag
|
|
89
121
|
@on_config.call(parsed.experiments)
|
|
90
122
|
end
|
|
91
123
|
end
|
data/lib/trevosdk/transport.rb
CHANGED
|
@@ -17,6 +17,11 @@ module Trevosdk
|
|
|
17
17
|
SERVER_MAX_BATCH_SIZE = 500
|
|
18
18
|
MAX_QUEUE_SIZE = 10_000
|
|
19
19
|
REQUEST_TIMEOUT_S = 10.0
|
|
20
|
+
# Ceiling on retry backoff, matching the browser SDK's MAX_FLUSH_BACKOFF_MS.
|
|
21
|
+
MAX_FLUSH_BACKOFF_S = 30.0
|
|
22
|
+
# close runs on the process-exit path, where a container's SIGTERM grace
|
|
23
|
+
# period is measured in seconds — an unbounded final flush gets SIGKILLed.
|
|
24
|
+
SHUTDOWN_FLUSH_TIMEOUT_S = 5.0
|
|
20
25
|
SERVER_EXPOSURE_DEDUP_MAX_KEYS = 100_000
|
|
21
26
|
FORCE_VARIANTS_ENV = "TREVO_FORCE_VARIANTS"
|
|
22
27
|
|
data/lib/trevosdk/version.rb
CHANGED
data/lib/trevosdk.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "trevosdk/client"
|
|
4
4
|
require_relative "trevosdk/core"
|
|
5
|
+
require_relative "trevosdk/errors"
|
|
5
6
|
require_relative "trevosdk/version"
|
|
6
7
|
|
|
7
8
|
module Trevosdk
|
|
@@ -9,8 +10,15 @@ module Trevosdk
|
|
|
9
10
|
# Process-wide singleton for the common one-client-per-app setup
|
|
10
11
|
# (e.g. a Rails config/initializers/trevosdk.rb).
|
|
11
12
|
def init(secret_key, **options)
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
# Build first, swap second, close last. Closing first meant a raising
|
|
14
|
+
# constructor — an unset TREVO_SECRET_KEY is one line of ENV away — left
|
|
15
|
+
# the singleton pointing at an already-closed client that `client` still
|
|
16
|
+
# returns, so every later call silently no-opped.
|
|
17
|
+
replacement = Client.new(secret_key, **options)
|
|
18
|
+
previous = @client
|
|
19
|
+
@client = replacement
|
|
20
|
+
previous&.close
|
|
21
|
+
replacement
|
|
14
22
|
end
|
|
15
23
|
|
|
16
24
|
def client
|
metadata
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: trevosdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Trevo
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies: []
|
|
13
|
-
description:
|
|
14
|
-
email:
|
|
15
12
|
executables: []
|
|
16
13
|
extensions: []
|
|
17
14
|
extra_rdoc_files: []
|
|
18
15
|
files:
|
|
16
|
+
- CHANGELOG.md
|
|
19
17
|
- README.md
|
|
20
18
|
- lib/trevosdk.rb
|
|
21
19
|
- lib/trevosdk/batcher.rb
|
|
22
20
|
- lib/trevosdk/client.rb
|
|
23
21
|
- lib/trevosdk/core.rb
|
|
22
|
+
- lib/trevosdk/errors.rb
|
|
24
23
|
- lib/trevosdk/poller.rb
|
|
25
24
|
- lib/trevosdk/transport.rb
|
|
26
25
|
- lib/trevosdk/version.rb
|
|
@@ -31,7 +30,6 @@ metadata:
|
|
|
31
30
|
homepage_uri: https://trevosdk.com
|
|
32
31
|
documentation_uri: https://docs.trevosdk.com
|
|
33
32
|
rubygems_mfa_required: 'true'
|
|
34
|
-
post_install_message:
|
|
35
33
|
rdoc_options: []
|
|
36
34
|
require_paths:
|
|
37
35
|
- lib
|
|
@@ -46,8 +44,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
46
44
|
- !ruby/object:Gem::Version
|
|
47
45
|
version: '0'
|
|
48
46
|
requirements: []
|
|
49
|
-
rubygems_version: 3.
|
|
50
|
-
signing_key:
|
|
47
|
+
rubygems_version: 3.6.9
|
|
51
48
|
specification_version: 4
|
|
52
49
|
summary: Trevo server SDK for Ruby — deterministic variant assignment and event tracking
|
|
53
50
|
test_files: []
|