featureflip 2.2.0 → 2.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fdefb980d91435aca01a6f6344804e172dedef867fd826c5ac9a017ca6416ca
4
- data.tar.gz: d98b0cda83055b8b984b2ea370b9717f6800dd44202ddacf21944fa19b0dc194
3
+ metadata.gz: fbe86937e4d8a9310f509da9c05ee1bcce31ebc4f633504249dd81429c586bbc
4
+ data.tar.gz: d71d56d4641775aafa8f11ef426adf40ea162e8a9eeb0ecafacb75998003c34d
5
5
  SHA512:
6
- metadata.gz: 360a193a8d0557ba71c45ce1c3f341d008f34f34996434328f7aa2c1da3eee9ef7cc531953ffbd21b8f83fa2674e750139c456854e59a322c0099fe04f76cc0f
7
- data.tar.gz: f4f292e068e49a8688b94c530d5cce50a060f97c57742e5aaee3f4db02aa469b0623c8b3c95065c6293e0452b71a58f3dc57212b649bc78e66044dad025c24a6
6
+ metadata.gz: f8bd1ce419e5d8ebf74696c34000e1fcb89eb9f34cf5136acb4fc28dff86ae91da031b16464ef1d4f74fed4141dfb3bbe2c1e0ef1651bb65dba3d268f6ad3419
7
+ data.tar.gz: 6dde7ff7991f6e29f88c56253578200ee7b981ed754c86c5c1555fb4dfa847e02f0e23542c7c1ce509ec26c631083342d16ca67433aef99fc50316cedc92ce9c
@@ -4,6 +4,11 @@ module Featureflip
4
4
  :flush_batch_size, :init_timeout, :connect_timeout, :read_timeout,
5
5
  :max_stream_retries, :send_events, :logger
6
6
 
7
+ # Evaluation inspectors -- callables invoked once per variation call with a
8
+ # Models::EvaluationEvent. Always an Array; non-callable entries are dropped
9
+ # on assignment rather than blowing up on the evaluation hot path.
10
+ attr_reader :inspectors
11
+
7
12
  def initialize(
8
13
  sdk_key: nil,
9
14
  base_url: "https://eval.featureflip.io",
@@ -16,7 +21,8 @@ module Featureflip
16
21
  read_timeout: 10,
17
22
  max_stream_retries: 5,
18
23
  send_events: true,
19
- logger: nil
24
+ logger: nil,
25
+ inspectors: nil
20
26
  )
21
27
  @sdk_key = sdk_key
22
28
  @base_url = base_url
@@ -30,10 +36,23 @@ module Featureflip
30
36
  @max_stream_retries = max_stream_retries
31
37
  @send_events = send_events
32
38
  @logger = logger || default_logger
39
+ self.inspectors = inspectors
33
40
 
34
41
  validate!
35
42
  end
36
43
 
44
+ # Accepts a single callable or an array of callables. Anything that does not
45
+ # respond to #call is filtered out here, so the evaluation path never has to
46
+ # guard against it.
47
+ def inspectors=(value)
48
+ list = case value
49
+ when nil then []
50
+ when Array then value
51
+ else [value]
52
+ end
53
+ @inspectors = list.select { |i| i.respond_to?(:call) }.freeze
54
+ end
55
+
37
56
  def validate!
38
57
  finalize!
39
58
  validate_positive_fields!
@@ -5,7 +5,38 @@ require "json"
5
5
  module Featureflip
6
6
  module DataSource
7
7
  class StreamingHandler
8
- def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_give_up: nil)
8
+ # Raised into the streaming thread by #stop to interrupt a blocking read
9
+ # (Thread#wakeup only wakes a *sleeping* thread; it can't interrupt an
10
+ # MRI IO read — Thread#raise can). Inherits from Exception, not
11
+ # StandardError, so it bypasses handle_event's/run's generic `rescue
12
+ # StandardError` arms (which would otherwise swallow a stop mid-event and
13
+ # leave the thread blocking) and is only caught by the explicit
14
+ # `rescue StreamStopped`.
15
+ class StreamStopped < Exception; end # rubocop:disable Lint/InheritException
16
+
17
+ # The server sends a keep-alive ping this often; a finite read timeout at
18
+ # or below this interval would sever a healthy stream.
19
+ SERVER_PING_INTERVAL_SECONDS = 30
20
+
21
+ # Client-side liveness watchdog. net/http gives us no separate heartbeat,
22
+ # so the read timeout IS the watchdog: if no data (not even a ping) arrives
23
+ # for this long the socket is treated as dead — a half-open connection
24
+ # (LB/NAT idle-drop or a partition with no FIN/RST) — and the blocking read
25
+ # raises Net::ReadTimeout, which drives reconnect/backoff/polling. Set to
26
+ # 3× the ping (3 missed pings) so it never severs a healthy stream but still
27
+ # detects a dead socket within a bounded time. MUST stay finite and
28
+ # > SERVER_PING_INTERVAL_SECONDS. (The rest of the server family now runs the
29
+ # same finite 90s watchdog — java readTimeout(90s) / python read=90.0 / csharp
30
+ # an idle-timeout CTS reset per event — so half-open detection is uniform.)
31
+ STREAM_READ_TIMEOUT = SERVER_PING_INTERVAL_SECONDS * 3
32
+
33
+ # Base reconnect backoff; also the floor applied after a healthy stream
34
+ # closes cleanly, so even an accept-then-immediately-close server is
35
+ # throttled instead of busy-looping.
36
+ RECONNECT_BASE_DELAY_SECONDS = 1
37
+ MAX_BACKOFF_SECONDS = 30
38
+
39
+ def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_sync: nil, on_give_up: nil)
9
40
  @sdk_key = sdk_key
10
41
  @config = config
11
42
  @http_client = http_client
@@ -13,12 +44,17 @@ module Featureflip
13
44
  @on_flag_deleted = on_flag_deleted
14
45
  @on_segment_updated = on_segment_updated
15
46
  @on_error = on_error
47
+ @on_sync = on_sync
16
48
  @on_give_up = on_give_up
17
49
  @stop_flag = false
18
50
  @thread = nil
19
51
  @retry_count = 0
20
52
  @current_event_type = nil
21
53
  @current_data = nil
54
+ @line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
55
+ @delivered_frame = false
56
+ @wake_mutex = Mutex.new
57
+ @wake_cond = ConditionVariable.new
22
58
  end
23
59
 
24
60
  def start
@@ -29,9 +65,21 @@ module Featureflip
29
65
 
30
66
  def stop
31
67
  @stop_flag = true
32
- @thread&.wakeup rescue nil
33
- @thread&.join(5)
68
+ # Wake an in-progress backoff wait.
69
+ @wake_mutex.synchronize { @wake_cond.broadcast }
70
+
71
+ thread = @thread
34
72
  @thread = nil
73
+ return unless thread
74
+
75
+ # Interrupt a thread blocked in read_body. Guard the raise: the thread may
76
+ # finish between the alive? check and the raise (ThreadError on a dead one).
77
+ begin
78
+ thread.raise(StreamStopped.new) if thread.alive?
79
+ rescue ThreadError
80
+ # Thread already finished — nothing to interrupt.
81
+ end
82
+ thread.join(5)
35
83
  end
36
84
 
37
85
  private
@@ -40,26 +88,57 @@ module Featureflip
40
88
  until @stop_flag
41
89
  begin
42
90
  connect
91
+ rescue StreamStopped
92
+ break
43
93
  rescue StandardError => e
44
94
  break if @stop_flag
45
95
  @on_error.call(e)
96
+ end
97
+ break if @stop_flag
98
+
99
+ # Consult @delivered_frame (the instance var), NOT connect's return
100
+ # value: connect only *returns* on a clean EOF, but the common stream
101
+ # terminations (the liveness-watchdog Net::ReadTimeout, ECONNRESET,
102
+ # IOError) RAISE — and a session that delivered frames before raising
103
+ # must still count as healthy, or transient blips accumulate and
104
+ # wrongly degrade a good stream to polling. @delivered_frame survives
105
+ # the exception; connect resets it to false at the top of each attempt.
106
+ if @delivered_frame
107
+ # The stream genuinely stayed up (delivered ≥1 frame — the server
108
+ # sends `sync` first). Reset the failure counter.
109
+ @retry_count = 0
110
+ else
111
+ # A clean EOF (no frame) is treated as a failure for backoff/escalation
112
+ # purposes — otherwise an accept-then-close server never accumulates
113
+ # toward max_stream_retries and never degrades to polling.
46
114
  @retry_count += 1
47
115
  if @retry_count > @config.max_stream_retries
48
116
  @on_give_up&.call
49
117
  break
50
118
  end
51
- delay = [2**(@retry_count - 1), 30].min
52
- sleep(delay)
53
119
  end
120
+
121
+ # Back off before every reconnect, including after a clean EOF, so we
122
+ # never zero-delay busy-loop against a flapping endpoint.
123
+ backoff_wait(backoff_delay(@retry_count))
54
124
  end
125
+ rescue StreamStopped
126
+ # stop() interrupted a backoff wait — clean shutdown.
55
127
  end
56
128
 
129
+ # Connect to the SSE stream and process events until the connection ends.
130
+ # Returns true if the stream delivered at least one complete frame (a live
131
+ # stream), false if it returned 200 but closed without delivering one.
57
132
  def connect
133
+ # Reset before anything can raise (a failed handshake / connection error
134
+ # must not let run() read a stale `true` from the previous session).
135
+ @delivered_frame = false
136
+
58
137
  uri = URI("#{@config.base_url}/v1/sdk/stream")
59
138
  http = Net::HTTP.new(uri.host, uri.port)
60
139
  http.use_ssl = uri.scheme == "https"
61
140
  http.open_timeout = @config.connect_timeout
62
- http.read_timeout = 300 # 5 min — detect silent TCP drops
141
+ http.read_timeout = STREAM_READ_TIMEOUT
63
142
 
64
143
  req = Net::HTTP::Get.new(uri.request_uri)
65
144
  req["Authorization"] = @sdk_key
@@ -71,31 +150,67 @@ module Featureflip
71
150
  raise Featureflip::Error, "SSE connection failed: #{response.code}"
72
151
  end
73
152
 
74
- @retry_count = 0
75
- @current_event_type = nil
76
- @current_data = nil
77
-
153
+ reset_stream_parser
78
154
  response.read_body do |chunk|
79
155
  break if @stop_flag
80
- chunk.each_line do |line|
81
- process_sse_line(line.strip)
82
- end
156
+ feed_chunk(chunk)
83
157
  end
84
158
  end
159
+
160
+ @delivered_frame
161
+ end
162
+
163
+ def reset_stream_parser
164
+ @current_event_type = nil
165
+ @current_data = nil
166
+ @line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
167
+ end
168
+
169
+ # Append a raw SSE body chunk and dispatch every *complete* line it
170
+ # completes. Net::HTTP#read_body yields arbitrary byte fragments with no
171
+ # line alignment, so a line (or a `data:` payload) can span chunk
172
+ # boundaries; buffer until a newline before parsing.
173
+ def feed_chunk(chunk)
174
+ @line_buffer << chunk
175
+ while (newline_index = @line_buffer.index("\n"))
176
+ line = @line_buffer.slice!(0, newline_index + 1)
177
+ process_sse_line(line.chomp.force_encoding(Encoding::UTF_8))
178
+ end
85
179
  end
86
180
 
87
181
  def process_sse_line(line)
88
182
  if line.start_with?("event: ")
89
183
  @current_event_type = line[7..]
90
184
  elsif line.start_with?("data: ")
91
- @current_data = line[6..]
185
+ # Per the SSE spec multiple data: lines join with "\n" — concatenate,
186
+ # never overwrite, or a chunked/multi-line payload loses everything but
187
+ # its last fragment.
188
+ fragment = line[6..]
189
+ @current_data = @current_data.nil? ? fragment : "#{@current_data}\n#{fragment}"
92
190
  elsif line.empty? && @current_event_type && @current_data
191
+ @delivered_frame = true
93
192
  handle_event(@current_event_type, @current_data)
94
193
  @current_event_type = nil
95
194
  @current_data = nil
96
195
  end
97
196
  end
98
197
 
198
+ # Capped exponential backoff. failures == 0 means a healthy stream just
199
+ # closed cleanly; still apply the base floor so we don't busy-loop.
200
+ def backoff_delay(failures)
201
+ exponent = failures <= 0 ? 0 : failures - 1
202
+ [RECONNECT_BASE_DELAY_SECONDS * (2**exponent), MAX_BACKOFF_SECONDS].min
203
+ end
204
+
205
+ # Sleep for `seconds`, but return immediately if stop() fires — so a pending
206
+ # shutdown isn't blocked behind a long backoff.
207
+ def backoff_wait(seconds)
208
+ @wake_mutex.synchronize do
209
+ return if @stop_flag
210
+ @wake_cond.wait(@wake_mutex, seconds)
211
+ end
212
+ end
213
+
99
214
  def handle_event(event_type, data)
100
215
  case event_type
101
216
  when "flag.created", "flag.updated"
@@ -112,6 +227,12 @@ module Featureflip
112
227
  when "segment.updated"
113
228
  flags, segments = @http_client.get_flags
114
229
  @on_segment_updated.call(flags, segments)
230
+ when "sync"
231
+ # Full config snapshot the server sends on (re)connect. Replace the
232
+ # whole store so flags changed OR deleted during a disconnect are
233
+ # re-synced. Full replace, never a per-key merge.
234
+ flags, segments = @http_client.parse_flags_response(JSON.parse(data))
235
+ @on_sync&.call(flags, segments)
115
236
  end
116
237
  rescue StandardError
117
238
  # Swallow event processing errors
@@ -13,7 +13,13 @@ module Featureflip
13
13
 
14
14
  def get_flags
15
15
  response = request(:get, "/v1/sdk/flags")
16
- data = JSON.parse(response.body)
16
+ parse_flags_response(JSON.parse(response.body))
17
+ end
18
+
19
+ # Parse a GET /v1/sdk/flags-shaped snapshot into models. Reused for the
20
+ # connect-time `sync` SSE snapshot, which carries the identical payload
21
+ # shape inline (no extra HTTP round-trip).
22
+ def parse_flags_response(data)
17
23
  flags = (data["flags"] || []).map { |f| parse_flag(f) }
18
24
  segments = (data["segments"] || []).map { |s| parse_segment(s) }
19
25
  [flags, segments]
@@ -0,0 +1,29 @@
1
+ module Featureflip
2
+ module Models
3
+ # The payload handed to every registered evaluation inspector, once per
4
+ # variation call. This is the frozen cross-SDK inspector contract (see
5
+ # docs/superpowers/specs/2026-07-13-sdk-onevaluation-inspector-design.md),
6
+ # spelled in Ruby snake_case:
7
+ #
8
+ # flag_key the flag key evaluated
9
+ # context the full evaluation context -- a copy, so mutating it
10
+ # cannot affect the caller's hash
11
+ # value the value the caller actually receives (default applied)
12
+ # variation_key winning arm; nil on flag-not-found and on error
13
+ # reason this SDK's native reason string (PascalCase, matching
14
+ # EvaluationDetail#reason -- deliberately NOT converted)
15
+ # rule_id set only on a rule match
16
+ # prerequisite_key set only on a prerequisite failure
17
+ # timestamp ISO-8601 string
18
+ EvaluationEvent = Struct.new(
19
+ :flag_key, :context, :value, :variation_key, :reason,
20
+ :rule_id, :prerequisite_key, :timestamp,
21
+ keyword_init: true
22
+ ) do
23
+ def initialize(flag_key:, context:, value:, reason:, timestamp:,
24
+ variation_key: nil, rule_id: nil, prerequisite_key: nil)
25
+ super
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,10 +1,36 @@
1
1
  require "timeout"
2
+ require "time"
2
3
 
3
4
  module Featureflip
4
5
  class SharedCore
5
6
  LIVE_CORES = {}
6
7
  LIVE_CORES_MUTEX = Mutex.new
7
8
 
9
+ # Exception classes that are deliberately NOT isolated when an evaluation
10
+ # inspector raises them (see #notify_inspectors). Everything else -- every
11
+ # StandardError, plus the Exception-but-not-StandardError classes a buggy
12
+ # callback realistically raises (NotImplementedError and other ScriptErrors,
13
+ # Minitest::Assertion, RSpec::Expectations::ExpectationNotMetError) -- is
14
+ # caught and logged so the caller's value is never affected.
15
+ #
16
+ # These four are re-raised because swallowing them would break something the
17
+ # inspector has no business breaking:
18
+ # SystemExit `exit`/`abort` -- the process is deliberately going down
19
+ # SignalException SIGTERM and (via its subclass Interrupt) Ctrl-C
20
+ # NoMemoryError the VM is out of memory; there is nothing safe to do
21
+ # SystemStackError the stack is blown; unwinding is the only safe move
22
+ # Timeout::ExitException is the private class `Timeout.timeout` throws into
23
+ # the running thread to unwind it; eating it would silently neutralise a
24
+ # caller that wrapped its variation call in a timeout.
25
+ INSPECTOR_UNISOLATED_ERRORS = [
26
+ SystemExit,
27
+ SignalException,
28
+ NoMemoryError,
29
+ SystemStackError,
30
+ (Timeout::ExitException if defined?(Timeout::ExitException))
31
+ ].compact.freeze
32
+ private_constant :INSPECTOR_UNISOLATED_ERRORS
33
+
8
34
  # --- Class-level factory methods ---
9
35
 
10
36
  def self._get_or_create(sdk_key, config)
@@ -52,6 +78,12 @@ module Featureflip
52
78
  def initialize(sdk_key:, config:)
53
79
  @sdk_key = sdk_key
54
80
  @config = config
81
+ # Snapshot the (already-filtered) inspector list at construction: config is
82
+ # immutable-after-init from the core's point of view, so the evaluation path
83
+ # needs no locking. Deliberately excluded from _configs_equal -- callables
84
+ # aren't structurally comparable and a differing inspector must not trigger
85
+ # the "different config" warning.
86
+ @inspectors = config.inspectors || []
55
87
  @store = Store::FlagStore.new
56
88
  @evaluator = Evaluation::Evaluator.new
57
89
  @initialized = false
@@ -124,6 +156,8 @@ module Featureflip
124
156
  context = normalize_context(context)
125
157
 
126
158
  if @test_mode
159
+ # Test-mode cores are built by _create_for_testing, which has no user
160
+ # config, so there are never inspectors to notify here.
127
161
  value = @test_values.fetch(key, default_value)
128
162
  reason = @test_values.key?(key) ? "Fallthrough" : "FlagNotFound"
129
163
  return Models::EvaluationDetail.new(value: value, reason: reason)
@@ -132,6 +166,7 @@ module Featureflip
132
166
  flag = @store.get_flag(key)
133
167
  unless flag
134
168
  record_evaluation(key, context, nil)
169
+ notify_inspectors(key, context, default_value, reason: "FlagNotFound")
135
170
  return Models::EvaluationDetail.new(value: default_value, reason: "FlagNotFound")
136
171
  end
137
172
 
@@ -141,12 +176,33 @@ module Featureflip
141
176
  get_segment: method(:get_segment),
142
177
  all_flags: @store.all_flags_map
143
178
  )
179
+
180
+ # Malformed config: the evaluator selected a variation key the flag does
181
+ # not define (e.g. a fallthrough/rule naming a since-deleted variation).
182
+ # Degrade to the caller's default and report Error, mirroring the engine's
183
+ # ServeVariation + the C#/Java SDKs (#1989). A variation that genuinely
184
+ # exists with a nil value is NOT this case -- hence the key lookup rather
185
+ # than a `value.nil?` check, which cannot tell the two apart.
186
+ reason = if result.variation_key && !result.variation_key.empty? &&
187
+ flag.get_variation(result.variation_key).nil?
188
+ "Error"
189
+ else
190
+ result.reason
191
+ end
192
+
144
193
  value = result.value.nil? ? default_value : result.value
145
194
  record_evaluation(key, context, result.variation_key)
195
+ notify_inspectors(
196
+ key, context, value,
197
+ reason: reason,
198
+ variation_key: result.variation_key,
199
+ rule_id: result.rule_id,
200
+ prerequisite_key: result.prerequisite_key
201
+ )
146
202
 
147
203
  Models::EvaluationDetail.new(
148
204
  value: value,
149
- reason: result.reason,
205
+ reason: reason,
150
206
  rule_id: result.rule_id,
151
207
  variation_key: result.variation_key,
152
208
  prerequisite_key: result.prerequisite_key
@@ -155,6 +211,7 @@ module Featureflip
155
211
  # Prerequisite-resolution failures return PrerequisiteFailed cleanly through
156
212
  # the evaluator; this rescue only fires on unexpected exceptions (malformed
157
213
  # config, programming errors), so prerequisite_key has no defined value.
214
+ notify_inspectors(key, context, default_value, reason: "Error")
158
215
  Models::EvaluationDetail.new(value: default_value, reason: "Error", prerequisite_key: nil)
159
216
  end
160
217
 
@@ -278,6 +335,7 @@ module Featureflip
278
335
  on_flag_updated: ->(flag) { @store.upsert(flag) },
279
336
  on_flag_deleted: ->(key) { @store.remove_flag(key) },
280
337
  on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
338
+ on_sync: ->(flags, segments) { @store.init(flags, segments) },
281
339
  on_error: ->(_err) { },
282
340
  on_give_up: -> { fallback_to_polling }
283
341
  )
@@ -341,9 +399,51 @@ module Featureflip
341
399
  })
342
400
  end
343
401
 
402
+ # Fire the registered evaluation inspectors. Called once per variation call
403
+ # on every exit path of variation_detail (success, flag-not-found, error)
404
+ # with the reason and value the caller actually receives. A raising inspector
405
+ # is isolated: it neither changes the returned value nor stops its siblings.
406
+ def notify_inspectors(flag_key, context, value, reason:, variation_key: nil,
407
+ rule_id: nil, prerequisite_key: nil)
408
+ return if @inspectors.nil? || @inspectors.empty?
409
+
410
+ event = Models::EvaluationEvent.new(
411
+ flag_key: flag_key,
412
+ # Shallow copy so a buggy inspector cannot mutate the caller's hash.
413
+ context: context.dup,
414
+ value: value,
415
+ variation_key: variation_key,
416
+ reason: reason,
417
+ rule_id: rule_id,
418
+ prerequisite_key: prerequisite_key,
419
+ # Millisecond precision, matching the sibling SDKs (PHP's "Y-m-d\TH:i:s.v\Z",
420
+ # C#'s "o", Python's isoformat). Whole-second stamps make an analytics sink
421
+ # that de-duplicates on (flag, user, timestamp) drop repeat exposures inside
422
+ # the same second, so the digit argument is load-bearing -- don't drop it.
423
+ timestamp: Time.now.utc.iso8601(3)
424
+ )
425
+
426
+ @inspectors.each do |inspector|
427
+ begin
428
+ inspector.call(event)
429
+ # Order matters: the un-isolated list is matched first, then everything
430
+ # else is contained. `rescue StandardError` is too narrow (an assertion
431
+ # failure or NotImplementedError from an inspector would escape into the
432
+ # caller's request handler, which the inspector contract forbids) and a
433
+ # bare `rescue Exception` is too wide (it would eat Ctrl-C). See
434
+ # INSPECTOR_UNISOLATED_ERRORS above before changing either arm.
435
+ rescue *INSPECTOR_UNISOLATED_ERRORS
436
+ raise
437
+ rescue Exception => e # rubocop:disable Lint/RescueException
438
+ @config.logger&.warn("Featureflip: evaluation inspector raised #{e.class}: #{e.message}")
439
+ end
440
+ end
441
+ end
442
+
344
443
  def init_test_mode(flags)
345
444
  @sdk_key = "test-key"
346
445
  @config = Config.new
446
+ @inspectors = []
347
447
  @store = Store::FlagStore.new
348
448
  @evaluator = Evaluation::Evaluator.new
349
449
  @initialized = true
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.2.0"
2
+ VERSION = "2.4.0"
3
3
  end
data/lib/featureflip.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "featureflip/config"
4
4
  require_relative "featureflip/models/flag"
5
5
  require_relative "featureflip/models/segment"
6
6
  require_relative "featureflip/models/evaluation_detail"
7
+ require_relative "featureflip/models/evaluation_event"
7
8
  require_relative "featureflip/evaluation/bucketing"
8
9
  require_relative "featureflip/evaluation/condition_evaluator"
9
10
  require_relative "featureflip/evaluation/evaluator"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureflip
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Featureflip
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '0.22'
61
+ version: '1.0'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '0.22'
68
+ version: '1.0'
69
69
  description: Server-side SDK for evaluating feature flags with Featureflip
70
70
  email:
71
71
  executables: []
@@ -85,6 +85,7 @@ files:
85
85
  - lib/featureflip/events/event_processor.rb
86
86
  - lib/featureflip/http/client.rb
87
87
  - lib/featureflip/models/evaluation_detail.rb
88
+ - lib/featureflip/models/evaluation_event.rb
88
89
  - lib/featureflip/models/flag.rb
89
90
  - lib/featureflip/models/segment.rb
90
91
  - lib/featureflip/shared_core.rb