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 +4 -4
- data/lib/featureflip/config.rb +20 -1
- data/lib/featureflip/data_source/streaming.rb +3 -3
- data/lib/featureflip/models/evaluation_event.rb +29 -0
- data/lib/featureflip/shared_core.rb +100 -1
- data/lib/featureflip/store/flag_store.rb +11 -1
- data/lib/featureflip/version.rb +1 -1
- data/lib/featureflip.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c796399a9211346f5354865f41d6cc5d0b62660d6f38efb6bc37c2861530a570
|
|
4
|
+
data.tar.gz: be799703942cd3fbe68f14a106913ce54e3bd2010d36e33be28bc356c59013b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '09edab24cf6796f2360677ffad03c5dbdd44a5382a0b9254d06e49b1d8883bd9e887e938f4f5f8d582911f8e681be7c571d008676a80cc7959f2b0181f05c563'
|
|
7
|
+
data.tar.gz: 5d18d268fd63154306ad2326f9fb8f7b5033f8c395e5eb43760ec0b291c59f7378e2cfbc71b80487a70fbf717cba72763b937b557971cb536ce79e1aa772f88d
|
data/lib/featureflip/config.rb
CHANGED
|
@@ -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
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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:
|
|
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
|
|
48
|
+
return if existing && existing.version > flag.version
|
|
39
49
|
@flags[flag.key] = flag
|
|
40
50
|
end
|
|
41
51
|
end
|
data/lib/featureflip/version.rb
CHANGED
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.
|
|
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-
|
|
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
|
|
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
|
|
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
|