featureflip 2.3.0 → 2.4.1

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: fcc479d4aca550b2c17f8a988df92f7a7406a8712765767311f9f712c56f1fd1
4
- data.tar.gz: c9281f409c2736e2c6b929cd39fb433e38969b496b59d7387b61de7ba0dc6ce5
3
+ metadata.gz: c796399a9211346f5354865f41d6cc5d0b62660d6f38efb6bc37c2861530a570
4
+ data.tar.gz: be799703942cd3fbe68f14a106913ce54e3bd2010d36e33be28bc356c59013b2
5
5
  SHA512:
6
- metadata.gz: 8935a5f95ab48a7ce6c05af6a832aa8eca8cd1b1d5e66c20475f4a7a9fec38ec186313269f98bbd4d766eca22ff36dec814854beee2bfca77229b3e7a62f0067
7
- data.tar.gz: 222fb9607a98518003f733a9abfcb3bb65143b33b05a5d4e7bf281e875573c33a4a28a83275a286328cb986cbdf622a1155f75e29188b5729a3f32a2f2ce906a
6
+ metadata.gz: '09edab24cf6796f2360677ffad03c5dbdd44a5382a0b9254d06e49b1d8883bd9e887e938f4f5f8d582911f8e681be7c571d008676a80cc7959f2b0181f05c563'
7
+ data.tar.gz: 5d18d268fd63154306ad2326f9fb8f7b5033f8c395e5eb43760ec0b291c59f7378e2cfbc71b80487a70fbf717cba72763b937b557971cb536ce79e1aa772f88d
@@ -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!
@@ -25,9 +25,9 @@ module Featureflip
25
25
  # raises Net::ReadTimeout, which drives reconnect/backoff/polling. Set to
26
26
  # 3× the ping (3 missed pings) so it never severs a healthy stream but still
27
27
  # detects a dead socket within a bounded time. MUST stay finite and
28
- # > SERVER_PING_INTERVAL_SECONDS. (The rest of the family runs an infinite
29
- # read timeout — java readTimeout(0) / python read=None / csharp #1526 —
30
- # and has the same latent half-open hole; ruby closes it here.)
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
31
  STREAM_READ_TIMEOUT = SERVER_PING_INTERVAL_SECONDS * 3
32
32
 
33
33
  # Base reconnect backoff; also the floor applied after a healthy stream
@@ -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
 
@@ -342,9 +399,51 @@ module Featureflip
342
399
  })
343
400
  end
344
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
+
345
443
  def init_test_mode(flags)
346
444
  @sdk_key = "test-key"
347
445
  @config = Config.new
446
+ @inspectors = []
348
447
  @store = Store::FlagStore.new
349
448
  @evaluator = Evaluation::Evaluator.new
350
449
  @initialized = true
@@ -32,10 +32,20 @@ module Featureflip
32
32
  @mutex.synchronize { @flags.dup }
33
33
  end
34
34
 
35
+ # Apply a single flag delta (SSE `flag.created` / `flag.updated`).
36
+ #
37
+ # Rejects only a *strictly older* config. Equal versions must be applied:
38
+ # the wire version is second-granular, so two edits to one flag inside the
39
+ # same wall-clock second carry an identical version, and treating equal as
40
+ # stale discarded the second edit outright. With streaming on there is no
41
+ # polling snapshot to correct it, so the store evaluated against the
42
+ # pre-edit config until an SSE `sync` or reconnect.
43
+ #
44
+ # Re-applying an identical config is harmless; dropping a real one is not.
35
45
  def upsert(flag)
36
46
  @mutex.synchronize do
37
47
  existing = @flags[flag.key]
38
- return if existing && existing.version >= flag.version
48
+ return if existing && existing.version > flag.version
39
49
  @flags[flag.key] = flag
40
50
  end
41
51
  end
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.3.0"
2
+ VERSION = "2.4.1"
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.3.0
4
+ version: 2.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Featureflip
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-13 00:00:00.000000000 Z
11
+ date: 2026-07-31 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