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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trevosdk
4
+ VERSION = "0.1.0"
5
+ 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: []