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 +4 -4
- data/lib/featureflip/config.rb +20 -1
- data/lib/featureflip/data_source/streaming.rb +135 -14
- data/lib/featureflip/http/client.rb +7 -1
- data/lib/featureflip/models/evaluation_event.rb +29 -0
- data/lib/featureflip/shared_core.rb +101 -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: fbe86937e4d8a9310f509da9c05ee1bcce31ebc4f633504249dd81429c586bbc
|
|
4
|
+
data.tar.gz: d71d56d4641775aafa8f11ef426adf40ea162e8a9eeb0ecafacb75998003c34d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8bd1ce419e5d8ebf74696c34000e1fcb89eb9f34cf5136acb4fc28dff86ae91da031b16464ef1d4f74fed4141dfb3bbe2c1e0ef1651bb65dba3d268f6ad3419
|
|
7
|
+
data.tar.gz: 6dde7ff7991f6e29f88c56253578200ee7b981ed754c86c5c1555fb4dfa847e02f0e23542c7c1ce509ec26c631083342d16ca67433aef99fc50316cedc92ce9c
|
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!
|
|
@@ -5,7 +5,38 @@ require "json"
|
|
|
5
5
|
module Featureflip
|
|
6
6
|
module DataSource
|
|
7
7
|
class StreamingHandler
|
|
8
|
-
|
|
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
|
-
|
|
33
|
-
@
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
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.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-
|
|
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
|
|
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
|