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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a962376d1586309cd51a06b89ebc86bf1fd6e150c04e74aede22a6565685ec40
4
- data.tar.gz: 30d9cc18e9c4b11a77230525f6402a6152dc3c21cb063ede29c3a7cc76fa4609
3
+ metadata.gz: c05a483b9ac99c124d83cf4eea80cedbac818112aea0a1187850355180bb7a0b
4
+ data.tar.gz: 9fefd4fd2d6670e124506c03304c1045934e62859b9aacfc6f1d57c5dc93458e
5
5
  SHA512:
6
- metadata.gz: 82076d6c8de3bf5f47a40be0b6a75581a7e9a24e4f04e7cf09c8814e60c6e68244078a83c059fefea83ee4770694bf28938d9d97eef67bb86814f6ba24f85c8e
7
- data.tar.gz: 18f482f337bb9d52d6c0bed6e505d4879fd8aacb5ccf96fdd5c652389a5c23cf4006935568c77eeec7bbc5b8a1efc0e2a25429f97e039549a0b7870792a4f72d
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
@@ -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
- return if @stopped
41
- if @queue.length >= MAX_QUEUE_SIZE
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
- @queue.push(event)
48
- over_batch = @queue.length >= @batch_size
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(RuntimeError.new("Trevo event queue full at #{MAX_QUEUE_SIZE}; dropping oldest events"))
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
- @mutex.synchronize { @stopped = true }
95
+ signal_stop
64
96
  wake!
65
97
  @worker&.join(@flush_interval_s + 1)
66
98
  @worker = nil
67
99
  begin
68
- flush
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
- @wake_mutex.synchronize do
86
- @wake_cv.wait(@wake_mutex, @flush_interval_s) unless @wake_flag
87
- @wake_flag = false
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 @mutex.synchronize { @stopped }
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
- pending = @mutex.synchronize do
100
- drained = @queue
101
- @queue = []
102
- drained
103
- end
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
- send_batch(batch)
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
- 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
- )
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
- pending[(start + batch.length)..]
125
- end
126
- @mutex.synchronize { @queue.unshift(*requeued) }
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
- 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)})
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",
@@ -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
- trimmed = ingestion_url.sub(%r{/+\z}, "")
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
- return Core::CONTROL_VARIANT if experiment_key.to_s.empty?
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 "Trevo alias rejected: anonymousId \"#{anonymous_id}\" is already linked to a different user"
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(RuntimeError.new("Trevo track(\"#{event_name}\") ignored: #{built.reason}"))
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
- return variant["name"].to_s if bucket < cumulative
65
- last_variant_name = variant["name"].to_s
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.strip.empty?
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
- return BuildEventResult.new(false, nil, "properties-too-large") if serialized.length > MAX_PROPERTIES_BYTES
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
- if (user_id.nil? || user_id.empty?) && (anonymous_id.nil? || anonymous_id.empty?)
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
- event = {"event" => event_name}
124
- event["userId"] = user_id unless user_id.nil?
125
- event["anonymousId"] = anonymous_id unless anonymous_id.nil?
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
@@ -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
- @ready_cv.wait(@ready_mutex, timeout_s) unless @ready
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&.join(1)
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 unless etag.nil? || etag.empty?
120
+ @etag = (etag.nil? || etag.empty?) ? nil : etag
89
121
  @on_config.call(parsed.experiments)
90
122
  end
91
123
  end
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Trevosdk
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
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
- @client&.close
13
- @client = Client.new(secret_key, **options)
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.0
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: 2026-08-17 00:00:00.000000000 Z
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.0.3.1
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: []