featureflip 2.5.0 → 2.6.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/data_source/streaming.rb +9 -0
- data/lib/featureflip/errors.rb +36 -0
- data/lib/featureflip/evaluation/condition_evaluator.rb +114 -31
- data/lib/featureflip/events/event_processor.rb +216 -19
- data/lib/featureflip/http/client.rb +119 -9
- data/lib/featureflip/shared_core.rb +45 -11
- data/lib/featureflip/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: edea6a4841539f13194e1e03e76bd7db298e713a0d79ec66327838cc56ed8408
|
|
4
|
+
data.tar.gz: 5a2d3f0946e19fb1734bc7db35dc1006f5d39f22626cbd147f527c31aa8dbddd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 894e77cfd032b59b62a8b1f9832ad5370d042c20290604b7f836bf9f35c427503f1b29eb3495c80fa02f816ae60e9d6c45c2d9f2074a9621aeb2cdaf1dd9d235
|
|
7
|
+
data.tar.gz: 472b4f2dbeb2dc1806a7e2dba1128b26bbe52a63833fcdd0c4571abc7b300b7d61ee153fe80cd35462c3782bbedee3b76695093c617a14600972e296f8c3ea53
|
|
@@ -234,6 +234,15 @@ module Featureflip
|
|
|
234
234
|
flags, segments = @http_client.parse_flags_response(JSON.parse(data))
|
|
235
235
|
@on_sync&.call(flags, segments)
|
|
236
236
|
end
|
|
237
|
+
rescue UnevaluableEntityError => e
|
|
238
|
+
# Not a malformed payload: the frame was well-formed and simply described
|
|
239
|
+
# behaviour this build cannot evaluate, so the entity was dropped rather than
|
|
240
|
+
# the payload discarded (#2402). Logged at the same volume — a flag that
|
|
241
|
+
# silently stopped updating is exactly as confusing as one that never arrived.
|
|
242
|
+
@config.logger&.warn(
|
|
243
|
+
"Featureflip: dropping #{event_type} update: #{e.message}. This SDK version " \
|
|
244
|
+
"may be older than the flag configuration."
|
|
245
|
+
)
|
|
237
246
|
rescue MalformedPayloadError => e
|
|
238
247
|
# A payload that violates the wire contract is discarded WHOLESALE rather
|
|
239
248
|
# than partially applied — a half-parsed snapshot silently mis-evaluates
|
data/lib/featureflip/errors.rb
CHANGED
|
@@ -11,4 +11,40 @@ module Featureflip
|
|
|
11
11
|
# operator to no-match. Only a TYPE violation is rejected, because that can never
|
|
12
12
|
# be a legitimate newer-server payload. See #2285.
|
|
13
13
|
class MalformedPayloadError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when the API answers a request with a non-success status. Carries the status
|
|
16
|
+
# so a caller can decide what to do about it.
|
|
17
|
+
#
|
|
18
|
+
# The events flush is the caller that has to: it re-queues a batch a later attempt could
|
|
19
|
+
# plausibly deliver (5xx, 429) and drops one the server will reject identically forever
|
|
20
|
+
# (401/403 = key rejected, 400 = malformed body). Before this the status only existed
|
|
21
|
+
# inside the message string, so every failure looked alike and the batch was dropped
|
|
22
|
+
# either way (#2456).
|
|
23
|
+
#
|
|
24
|
+
# Subclasses Error and keeps the historical "HTTP <code>: <path>" message, so callers
|
|
25
|
+
# rescuing Featureflip::Error — or matching on that message — are unaffected.
|
|
26
|
+
class HttpStatusError < Error
|
|
27
|
+
attr_reader :status
|
|
28
|
+
|
|
29
|
+
def initialize(status, path)
|
|
30
|
+
@status = status
|
|
31
|
+
super("HTTP #{status}: #{path}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raised when a flag or segment carries an enum value this SDK build cannot
|
|
36
|
+
# EVALUATE — an unrecognised `serve.type` or `conditionLogic` (#2402).
|
|
37
|
+
#
|
|
38
|
+
# Distinct from MalformedPayloadError, and the distinction is the whole point. That
|
|
39
|
+
# one means the payload is the wrong SHAPE and is discarded wholesale. This one means
|
|
40
|
+
# the payload is perfectly well-formed and simply describes behaviour a newer server
|
|
41
|
+
# understands and this build does not — so only the containing ENTITY is dropped and
|
|
42
|
+
# the rest of the configuration still applies.
|
|
43
|
+
#
|
|
44
|
+
# Not raised for an unrecognised condition OPERATOR: the evaluator already fails an
|
|
45
|
+
# unknown operator closed (#2262), so the condition just does not match and the flag
|
|
46
|
+
# remains perfectly evaluable. `serve.type` and `conditionLogic` have no such
|
|
47
|
+
# fail-closed arm — each dispatches on a two-way branch and an unrecognised value
|
|
48
|
+
# takes the ELSE arm, silently serving a rollout or matching ANY condition.
|
|
49
|
+
class UnevaluableEntityError < Error; end
|
|
14
50
|
end
|
|
@@ -32,6 +32,14 @@ module Featureflip
|
|
|
32
32
|
targets = condition.values.map(&:to_s)
|
|
33
33
|
|
|
34
34
|
result = evaluate_operator(condition.operator, str_value, targets)
|
|
35
|
+
|
|
36
|
+
# Issue #2262: an unrecognised operator fails CLOSED. `!nil` is `true`
|
|
37
|
+
# in Ruby, so without this guard a negated unknown operator would match
|
|
38
|
+
# every user and roll the flag out to 100% of traffic. The realistic
|
|
39
|
+
# trigger is a new operator shipped server-side reaching an SDK pinned
|
|
40
|
+
# to an older version.
|
|
41
|
+
return false if result.nil?
|
|
42
|
+
|
|
35
43
|
condition.negate ? !result : result
|
|
36
44
|
end
|
|
37
45
|
|
|
@@ -66,16 +74,32 @@ module Featureflip
|
|
|
66
74
|
SemverVersion = Struct.new(:release, :prerelease)
|
|
67
75
|
private_constant :SemverVersion
|
|
68
76
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
# The ONLY characters trimmed from a date operand, and the whole of the
|
|
78
|
+
# operand's permitted whitespace: U+0009..U+000D plus U+0020 -- exactly the
|
|
79
|
+
# class the engine's NumberStyles.Integer accepts via AllowLeadingWhite |
|
|
80
|
+
# AllowTrailingWhite.
|
|
81
|
+
#
|
|
82
|
+
# String#strip is deliberately NOT used: it also strips NUL, so "\0005" was
|
|
83
|
+
# trimmed to "5" and matched here while the engine rejected it (#2468).
|
|
84
|
+
OPERAND_WHITESPACE = "\t\n\v\f\r "
|
|
85
|
+
private_constant :OPERAND_WHITESPACE
|
|
86
|
+
|
|
87
|
+
# Characters no date operand may contain: a NUL or other control character, or
|
|
88
|
+
# a non-ASCII whitespace/format character. An interior ASCII space is allowed
|
|
89
|
+
# -- it is the ISO-8601 date/time separator.
|
|
90
|
+
FORBIDDEN_OPERAND_CHAR =
|
|
91
|
+
/[\u0000-\u001f\u007f-\u009f\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/
|
|
92
|
+
private_constant :FORBIDDEN_OPERAND_CHAR
|
|
93
|
+
|
|
94
|
+
# The ISO-8601 grammar a date operand may use: a calendar date, optionally
|
|
95
|
+
# followed by a time (seconds and fractional seconds optional) and an optional
|
|
96
|
+
# offset in either extended (+05:00 / Z) or basic (+0500) form. The separator
|
|
97
|
+
# may be "T" or a space -- the engine accepts both, but Time.iso8601 rejects
|
|
98
|
+
# the space, which is why ruby alone read "2024-01-01 00:00:00" as no-match
|
|
99
|
+
# (#2468).
|
|
100
|
+
ISO_OPERAND =
|
|
101
|
+
/\A(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
|
|
102
|
+
private_constant :ISO_OPERAND
|
|
79
103
|
|
|
80
104
|
def evaluate_operator(operator, value, targets)
|
|
81
105
|
# Case-insensitive views for the string/relational/date operators.
|
|
@@ -150,7 +174,10 @@ module Featureflip
|
|
|
150
174
|
when "SemverLessThanOrEqual"
|
|
151
175
|
targets.any? { |t| compare_semver(value, t, :<=) }
|
|
152
176
|
else
|
|
153
|
-
false
|
|
177
|
+
# Unrecognised operator. `nil` — NOT `false` — so the caller can tell
|
|
178
|
+
# "cannot evaluate" apart from "evaluated, did not match"; only the
|
|
179
|
+
# latter may be inverted by `negate` (#2262).
|
|
180
|
+
nil
|
|
154
181
|
end
|
|
155
182
|
end
|
|
156
183
|
|
|
@@ -209,31 +236,87 @@ module Featureflip
|
|
|
209
236
|
# TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
|
|
210
237
|
# without an offset is assumed UTC. A bare integer is treated as Unix time
|
|
211
238
|
# in seconds. Returns nil when the input parses as neither.
|
|
239
|
+
# DateTimeOffset.MinValue / MaxValue as unix seconds -- the exact bounds the
|
|
240
|
+
# engine's FromUnixTimeSeconds accepts before throwing (#2432).
|
|
241
|
+
MIN_UNIX_SECONDS = -62_135_596_800
|
|
242
|
+
MAX_UNIX_SECONDS = 253_402_300_799
|
|
243
|
+
|
|
244
|
+
# Rewrites an accepted ISO operand into the strict extended form Time.iso8601
|
|
245
|
+
# parses: "T" separator, seconds present, offset spelled "+HH:MM" or "Z".
|
|
246
|
+
# Returns nil when the operand is not an accepted ISO shape.
|
|
247
|
+
def canonicalize_iso(s)
|
|
248
|
+
m = ISO_OPERAND.match(s)
|
|
249
|
+
return nil if m.nil?
|
|
250
|
+
|
|
251
|
+
date, hh, mm, ss, frac, off = m.captures
|
|
252
|
+
return "#{date}T00:00:00Z" if hh.nil?
|
|
253
|
+
|
|
254
|
+
# The engine's DateTimeOffset.TryParse rejects hour 24 outright rather than
|
|
255
|
+
# rolling it over to 00:00 the next day, which is what Time.iso8601 does.
|
|
256
|
+
return nil if hh >= "24"
|
|
257
|
+
|
|
258
|
+
ss ||= "00"
|
|
259
|
+
off =
|
|
260
|
+
if off.nil? then "Z"
|
|
261
|
+
elsif off.length == 5 && off != "Z" then "#{off[0, 3]}:#{off[3, 2]}"
|
|
262
|
+
else off
|
|
263
|
+
end
|
|
264
|
+
"#{date}T#{hh}:#{mm}:#{ss}#{frac}#{off}"
|
|
265
|
+
end
|
|
266
|
+
|
|
212
267
|
def parse_datetime(value)
|
|
213
|
-
s = value.to_s
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
rescue ArgumentError
|
|
230
|
-
# Fall through to the Unix-seconds fallback.
|
|
268
|
+
s = value.to_s
|
|
269
|
+
# Trim exactly the engine's whitespace class, then reject anything still
|
|
270
|
+
# carrying a character no operand may contain.
|
|
271
|
+
s = s.gsub(/\A[#{Regexp.escape(OPERAND_WHITESPACE)}]+|[#{Regexp.escape(OPERAND_WHITESPACE)}]+\z/, "")
|
|
272
|
+
return nil if s.empty? || s.match?(FORBIDDEN_OPERAND_CHAR)
|
|
273
|
+
|
|
274
|
+
iso = canonicalize_iso(s)
|
|
275
|
+
if iso
|
|
276
|
+
begin
|
|
277
|
+
# Offset-less forms were canonicalized to an explicit "Z", mirroring
|
|
278
|
+
# DateTimeOffset.TryParse with AssumeUniversal.
|
|
279
|
+
return Time.iso8601(iso).utc
|
|
280
|
+
rescue ArgumentError
|
|
281
|
+
# A syntactically-valid but non-existent date (e.g. 2024-02-31) --
|
|
282
|
+
# fall through to the Unix-seconds fallback, which will also reject it.
|
|
283
|
+
end
|
|
231
284
|
end
|
|
232
285
|
|
|
233
286
|
# Integer fallback: treat a bare integer as Unix time in seconds.
|
|
234
|
-
|
|
287
|
+
#
|
|
288
|
+
# Out-of-range seconds match NOTHING rather than resolving to a far-future
|
|
289
|
+
# instant: the engine's FromUnixTimeSeconds throws outside DateTimeOffset's
|
|
290
|
+
# range and TryParseDateTime returns false. Ruby's Time has a far wider range
|
|
291
|
+
# and would happily accept the value, so the bound has to be explicit. The
|
|
292
|
+
# case that matters in practice is a MILLISECONDS timestamp pasted where
|
|
293
|
+
# seconds belong, which would otherwise land in the year 55829 and satisfy
|
|
294
|
+
# every `After` comparison (#2432).
|
|
295
|
+
#
|
|
296
|
+
# The sign class matches the engine's `long.TryParse` with
|
|
297
|
+
# `NumberStyles.Integer` (`AllowLeadingWhite | AllowTrailingWhite |
|
|
298
|
+
# AllowLeadingSign`), so a leading "+" is accepted deliberately rather than
|
|
299
|
+
# incidentally, and `Integer()` reads it the same way. Omitting it made "+5"
|
|
300
|
+
# an unparseable string matching NOTHING here while the engine and four other
|
|
301
|
+
# SDKs read it as five seconds past the epoch (#2458).
|
|
302
|
+
#
|
|
303
|
+
# The whitespace flags now match too: the trim above is exactly
|
|
304
|
+
# `AllowLeadingWhite`/`AllowTrailingWhite`'s class, and anything outside it
|
|
305
|
+
# was already rejected by FORBIDDEN_OPERAND_CHAR (#2468).
|
|
306
|
+
if s.match?(/\A[+-]?\d+\z/)
|
|
235
307
|
begin
|
|
236
|
-
|
|
308
|
+
# Base 10 EXPLICITLY. Bare `Integer(s)` honours Ruby's literal base
|
|
309
|
+
# prefixes, so a leading zero means OCTAL: "0500" became 320 rather than
|
|
310
|
+
# 500, and "0800" raised ArgumentError (8 is not an octal digit) and
|
|
311
|
+
# matched nothing at all. Every other implementation parses base 10 --
|
|
312
|
+
# the engine's `long.TryParse`, go's `ParseInt(s, 10, 64)`, java's
|
|
313
|
+
# `Long.parseLong`, python's `int()`, php's `(int)` cast and js's
|
|
314
|
+
# `Number()` -- so ruby was alone in reading a zero-padded unix timestamp
|
|
315
|
+
# as a different instant. Pinned by `c-date-unix-leading-zero-*` (#2458).
|
|
316
|
+
seconds = Integer(s, 10)
|
|
317
|
+
return nil if seconds < MIN_UNIX_SECONDS || seconds > MAX_UNIX_SECONDS
|
|
318
|
+
|
|
319
|
+
return Time.at(seconds).utc
|
|
237
320
|
rescue RangeError, ArgumentError
|
|
238
321
|
return nil
|
|
239
322
|
end
|
|
@@ -1,38 +1,75 @@
|
|
|
1
1
|
module Featureflip
|
|
2
2
|
module Events
|
|
3
3
|
class EventProcessor
|
|
4
|
-
|
|
4
|
+
# Upper bound on buffered events.
|
|
5
|
+
#
|
|
6
|
+
# Only reachable once #flush starts putting batches back faster than they drain --
|
|
7
|
+
# i.e. a sustained outage of the events endpoint. Past the bound the OLDEST events
|
|
8
|
+
# are shed, which caps memory and keeps the freshest analytics. It also means a long
|
|
9
|
+
# outage sheds the stale re-queued batches rather than starving new events, so the
|
|
10
|
+
# SDK degrades to the old drop-on-failure behaviour instead of hoarding data it
|
|
11
|
+
# cannot send.
|
|
12
|
+
DEFAULT_MAX_QUEUE_SIZE = 10_000
|
|
13
|
+
|
|
14
|
+
def initialize(http_client, flush_interval: 30, flush_batch_size: 100,
|
|
15
|
+
max_queue_size: DEFAULT_MAX_QUEUE_SIZE, logger: nil)
|
|
5
16
|
@http_client = http_client
|
|
6
17
|
@flush_interval = flush_interval
|
|
7
|
-
@flush_batch_size
|
|
18
|
+
# Clamped to at least 1: #flush drains @flush_batch_size events per pass and stops
|
|
19
|
+
# when the queue is empty, so a non-positive size would shift nothing off a
|
|
20
|
+
# non-empty queue and spin forever. Config#validate! already rejects such values,
|
|
21
|
+
# but this class is constructed directly too.
|
|
22
|
+
@flush_batch_size = flush_batch_size.to_i.positive? ? flush_batch_size.to_i : 1
|
|
23
|
+
@max_queue_size = max_queue_size.to_i.positive? ? max_queue_size.to_i : DEFAULT_MAX_QUEUE_SIZE
|
|
24
|
+
@logger = logger
|
|
8
25
|
@queue = []
|
|
9
26
|
@mutex = Mutex.new
|
|
10
27
|
@stop_flag = false
|
|
28
|
+
@stopped = false
|
|
11
29
|
@thread = nil
|
|
30
|
+
|
|
31
|
+
# Monotonic instant before which the batch-size trigger must not start another
|
|
32
|
+
# flush, and a latch held while a size-triggered flush is in flight. Both exist to
|
|
33
|
+
# stop a re-queued batch from turning every subsequent event into another request
|
|
34
|
+
# — see #auto_flush.
|
|
35
|
+
@next_auto_flush_at = 0.0
|
|
36
|
+
@auto_flush_in_flight = false
|
|
12
37
|
end
|
|
13
38
|
|
|
14
39
|
def queue_event(event)
|
|
15
|
-
|
|
40
|
+
dropped = 0
|
|
16
41
|
@mutex.synchronize do
|
|
42
|
+
# After #stop nothing will flush again, so buffering here would only leak.
|
|
43
|
+
return if @stopped
|
|
44
|
+
|
|
17
45
|
@queue << event
|
|
18
|
-
|
|
46
|
+
dropped = trim_to_bound
|
|
19
47
|
end
|
|
20
|
-
|
|
48
|
+
|
|
49
|
+
warn_overflow(dropped)
|
|
50
|
+
auto_flush
|
|
21
51
|
end
|
|
22
52
|
|
|
53
|
+
# Drains the queue a batch at a time, one request per batch.
|
|
54
|
+
#
|
|
55
|
+
# This used to post the WHOLE queue in a single request, which was harmless while a
|
|
56
|
+
# failure emptied the queue: it never grew far past @flush_batch_size. Re-queuing
|
|
57
|
+
# failures (#2456) is what changed that — after a sustained outage the queue can sit
|
|
58
|
+
# at its 10,000-event bound, and posting all of that at once risks a body the server
|
|
59
|
+
# rejects outright. A 413 is non-retryable, so the entire backlog would be dropped by
|
|
60
|
+
# the very path added to preserve it.
|
|
23
61
|
def flush
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return if
|
|
27
|
-
events_to_send = @queue.dup
|
|
28
|
-
@queue.clear
|
|
29
|
-
end
|
|
62
|
+
loop do
|
|
63
|
+
batch = drain_batch
|
|
64
|
+
return if batch.empty?
|
|
30
65
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
# False means the batch went back on the queue, and it is at the HEAD of the very
|
|
67
|
+
# queue this loop drains — carrying on would re-send it immediately and spin for
|
|
68
|
+
# as long as the endpoint stayed down. A dropped batch returns true instead: the
|
|
69
|
+
# queue has shrunk, so the loop still terminates, and one poison batch must not
|
|
70
|
+
# block the backlog behind it.
|
|
71
|
+
return unless send_batch(batch)
|
|
72
|
+
end
|
|
36
73
|
end
|
|
37
74
|
|
|
38
75
|
def start
|
|
@@ -42,10 +79,15 @@ module Featureflip
|
|
|
42
79
|
until @stop_flag
|
|
43
80
|
sleep(1)
|
|
44
81
|
elapsed += 1
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
next if @stop_flag
|
|
83
|
+
|
|
84
|
+
if elapsed >= @flush_interval
|
|
85
|
+
# The interval tick is the retry vehicle for a re-queued batch, so it is
|
|
86
|
+
# deliberately NOT subject to the size trigger's backoff gate.
|
|
87
|
+
elapsed = 0
|
|
88
|
+
flush
|
|
89
|
+
elsif auto_flush
|
|
47
90
|
elapsed = 0
|
|
48
|
-
flush unless @stop_flag
|
|
49
91
|
end
|
|
50
92
|
end
|
|
51
93
|
end
|
|
@@ -56,7 +98,162 @@ module Featureflip
|
|
|
56
98
|
@thread&.wakeup rescue nil
|
|
57
99
|
@thread&.join(5)
|
|
58
100
|
@thread = nil
|
|
101
|
+
|
|
102
|
+
# Closed BEFORE the final flush so a failure there is dropped rather than
|
|
103
|
+
# re-queued: nothing will flush again, and retrying until the queue drains would
|
|
104
|
+
# hang shutdown for as long as the endpoint stayed down. One attempt, then let go.
|
|
105
|
+
@mutex.synchronize { @stopped = true }
|
|
59
106
|
flush
|
|
107
|
+
@mutex.synchronize { @queue.clear }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Batch-size-triggered flush. Returns true only if it actually sent.
|
|
113
|
+
#
|
|
114
|
+
# A re-queued batch leaves the queue at or above @flush_batch_size, so without a gate
|
|
115
|
+
# every subsequent event would trigger another flush — turning a failing endpoint
|
|
116
|
+
# into one request per evaluation, which is worse for the server than the dropping
|
|
117
|
+
# this replaced. Two guards, and both are load-bearing:
|
|
118
|
+
#
|
|
119
|
+
# * the backoff gate suppresses the size trigger for one flush interval after a
|
|
120
|
+
# retryable failure, leaving the background thread's interval tick as the retry
|
|
121
|
+
# vehicle;
|
|
122
|
+
# * the in-flight latch covers what the gate cannot, because the gate is only armed
|
|
123
|
+
# once a flush has already FAILED and the size trigger fires again long before
|
|
124
|
+
# the first round-trip returns. Without it a tight loop of events starts a pile
|
|
125
|
+
# of concurrent flushes.
|
|
126
|
+
#
|
|
127
|
+
# An explicit #flush (the public API, and the interval tick) bypasses both: the
|
|
128
|
+
# caller asked for a send.
|
|
129
|
+
def auto_flush
|
|
130
|
+
return false unless @mutex.synchronize { claim_size_trigger }
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
flush
|
|
134
|
+
ensure
|
|
135
|
+
@mutex.synchronize { @auto_flush_in_flight = false }
|
|
136
|
+
end
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Whether a size-triggered flush may start, claiming the latch if so.
|
|
141
|
+
# Caller holds @mutex.
|
|
142
|
+
def claim_size_trigger
|
|
143
|
+
return false if @stopped
|
|
144
|
+
return false if @auto_flush_in_flight
|
|
145
|
+
return false if @queue.length < @flush_batch_size
|
|
146
|
+
return false if monotonic_now < @next_auto_flush_at
|
|
147
|
+
|
|
148
|
+
@auto_flush_in_flight = true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Never called with @mutex held: the POST blocks for as long as the endpoint takes to
|
|
152
|
+
# answer — and Http::Client sleeps a second before its own single inline retry — so
|
|
153
|
+
# the background thread and every queue_event caller would block behind it.
|
|
154
|
+
# Sends one batch. Returns true if #flush may go on to the next one — see the comment
|
|
155
|
+
# at its call site for why a re-queued batch must stop the drain.
|
|
156
|
+
def send_batch(events)
|
|
157
|
+
@http_client.post_events(events)
|
|
158
|
+
@mutex.synchronize { @next_auto_flush_at = 0.0 }
|
|
159
|
+
true
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
unless retryable_failure?(e)
|
|
162
|
+
@logger&.warn(
|
|
163
|
+
"Featureflip: dropped #{events.length} analytics event(s) the events endpoint " \
|
|
164
|
+
"rejected (#{e.class}: #{e.message}); the failure is not retryable"
|
|
165
|
+
)
|
|
166
|
+
return true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
dropped = requeue(events)
|
|
170
|
+
# Armed here rather than at the first attempt, so the interval is counted from the
|
|
171
|
+
# moment post_events' inline retry finally gave up.
|
|
172
|
+
@mutex.synchronize { @next_auto_flush_at = monotonic_now + @flush_interval }
|
|
173
|
+
|
|
174
|
+
if dropped.nil?
|
|
175
|
+
@logger&.warn(
|
|
176
|
+
"Featureflip: dropped #{events.length} analytics event(s) " \
|
|
177
|
+
"(#{e.class}: #{e.message}); the processor is shutting down and will not flush again"
|
|
178
|
+
)
|
|
179
|
+
else
|
|
180
|
+
@logger&.warn(
|
|
181
|
+
"Featureflip: failed to send #{events.length} analytics event(s) " \
|
|
182
|
+
"(#{e.class}: #{e.message}); re-queued for the next flush"
|
|
183
|
+
)
|
|
184
|
+
warn_overflow(dropped)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Whether the same batch could succeed if it were sent again.
|
|
191
|
+
#
|
|
192
|
+
# For an HTTP answer the status decides: any 5xx — the production edge answers this
|
|
193
|
+
# endpoint with a 503 at a low but constant rate (#2456) — and 429, where the server
|
|
194
|
+
# is explicitly asking us to come back later. Anything else will fail identically next
|
|
195
|
+
# time: 401/403 means the SDK key was rejected, 400 means the body is malformed, and
|
|
196
|
+
# retrying either forever would pin the queue at its bound and starve every later
|
|
197
|
+
# event.
|
|
198
|
+
#
|
|
199
|
+
# Everything that is NOT an HTTP answer is treated as transient. A transport fault
|
|
200
|
+
# (connection reset, DNS, TLS) or a timeout carries no status at all and is exactly
|
|
201
|
+
# the kind of failure a later flush gets past, so the default has to be "keep it"
|
|
202
|
+
# rather than "not a 5xx, therefore permanent".
|
|
203
|
+
def retryable_failure?(error)
|
|
204
|
+
return error.status >= 500 || error.status == 429 if error.is_a?(Featureflip::HttpStatusError)
|
|
205
|
+
|
|
206
|
+
true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Takes up to @flush_batch_size of the OLDEST events, leaving the rest queued.
|
|
210
|
+
def drain_batch
|
|
211
|
+
@mutex.synchronize do
|
|
212
|
+
return [] if @queue.empty?
|
|
213
|
+
|
|
214
|
+
@queue.shift([@flush_batch_size, @queue.length].min)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Puts a batch that failed to send back at the FRONT of the queue, so the next flush
|
|
219
|
+
# retries it ahead of newer events and rough chronological order survives.
|
|
220
|
+
#
|
|
221
|
+
# Returns how many events were shed to stay within the bound — or nil if it refused
|
|
222
|
+
# the batch entirely because #stop has closed the queue. The caller needs to tell
|
|
223
|
+
# those apart: "re-queued for the next flush" is a lie once there will be no next
|
|
224
|
+
# flush, and this is the branch a shutdown during an outage takes.
|
|
225
|
+
def requeue(events)
|
|
226
|
+
@mutex.synchronize do
|
|
227
|
+
return nil if @stopped
|
|
228
|
+
|
|
229
|
+
@queue.unshift(*events)
|
|
230
|
+
trim_to_bound
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Sheds oldest-first until the queue fits the bound, returning how many went.
|
|
235
|
+
# Caller holds @mutex.
|
|
236
|
+
def trim_to_bound
|
|
237
|
+
overflow = @queue.length - @max_queue_size
|
|
238
|
+
return 0 if overflow <= 0
|
|
239
|
+
|
|
240
|
+
@queue.shift(overflow)
|
|
241
|
+
overflow
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def warn_overflow(dropped)
|
|
245
|
+
return if dropped.zero?
|
|
246
|
+
|
|
247
|
+
@logger&.warn(
|
|
248
|
+
"Featureflip: event queue is full (#{@max_queue_size}); " \
|
|
249
|
+
"dropped #{dropped} of the oldest analytics event(s)"
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Wall-clock time can jump backwards (NTP, a suspended host); the backoff gate must
|
|
254
|
+
# not be extended or skipped by that.
|
|
255
|
+
def monotonic_now
|
|
256
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
60
257
|
end
|
|
61
258
|
end
|
|
62
259
|
end
|
|
@@ -20,18 +20,37 @@ module Featureflip
|
|
|
20
20
|
# connect-time `sync` SSE snapshot, which carries the identical payload
|
|
21
21
|
# shape inline (no extra HTTP round-trip).
|
|
22
22
|
def parse_flags_response(data)
|
|
23
|
-
flags = (data["flags"] || []).map { |f| parse_flag(f) }
|
|
24
|
-
|
|
23
|
+
flags = drop_unevaluable((data["flags"] || []).map { |f| parse_flag(f) }, "flag") do |flag|
|
|
24
|
+
unevaluable_flag_reason(flag)
|
|
25
|
+
end
|
|
26
|
+
segments = drop_unevaluable((data["segments"] || []).map { |s| parse_segment(s) }, "segment") do |segment|
|
|
27
|
+
unevaluable_segment_reason(segment)
|
|
28
|
+
end
|
|
25
29
|
[flags, segments]
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def get_flag(key)
|
|
29
33
|
response = request(:get, "/v1/sdk/flags/#{key}")
|
|
30
|
-
parse_flag(JSON.parse(response.body))
|
|
34
|
+
flag = parse_flag(JSON.parse(response.body))
|
|
35
|
+
|
|
36
|
+
# An unevaluable enum drops the flag rather than upserting it (#2402). For a
|
|
37
|
+
# delta whose whole scope is one flag that means leaving the store's previous
|
|
38
|
+
# copy alone: replacing it with one this build would mis-evaluate is the outcome
|
|
39
|
+
# the drop exists to prevent, and FLAG_NOT_FOUND is the honest answer if there
|
|
40
|
+
# was no previous copy.
|
|
41
|
+
reason = unevaluable_flag_reason(flag)
|
|
42
|
+
raise UnevaluableEntityError, "flag #{key.inspect}: #{reason}" if reason
|
|
43
|
+
|
|
44
|
+
flag
|
|
31
45
|
end
|
|
32
46
|
|
|
33
47
|
def post_events(events)
|
|
34
|
-
|
|
48
|
+
# The one caller that retries inline. EventProcessor#flush drains the queue before
|
|
49
|
+
# sending, so a batch only survives a failure because the processor puts it back --
|
|
50
|
+
# this absorbs a single transient 5xx before that machinery is needed, which keeps
|
|
51
|
+
# the common blip off the re-queue path entirely. The processor's backoff gate is
|
|
52
|
+
# measured from the moment this finally gives up, not from the first attempt.
|
|
53
|
+
request(:post, "/v1/sdk/events", { events: events }, retry_server_errors: true)
|
|
35
54
|
end
|
|
36
55
|
|
|
37
56
|
def close
|
|
@@ -40,7 +59,13 @@ module Featureflip
|
|
|
40
59
|
|
|
41
60
|
private
|
|
42
61
|
|
|
43
|
-
|
|
62
|
+
# retry_server_errors: retry once on a 5xx. Off by default — the poller re-fetches
|
|
63
|
+
# every poll_interval and the streaming source reconnects with backoff, so for flag
|
|
64
|
+
# reads an inner retry buys nothing and doubles request volume against a dependency
|
|
65
|
+
# that is already failing (it also blocked for a second inside the init_timeout
|
|
66
|
+
# budget on cold start). eval-api answers 503 when it cannot reach the Management
|
|
67
|
+
# API, which is exactly the status this used to trip on.
|
|
68
|
+
def request(method, path, body = nil, retries: 1, retry_server_errors: false)
|
|
44
69
|
uri = URI("#{@base_url}#{path}")
|
|
45
70
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
46
71
|
http.use_ssl = uri.scheme == "https"
|
|
@@ -62,13 +87,16 @@ module Featureflip
|
|
|
62
87
|
|
|
63
88
|
response = http.request(req)
|
|
64
89
|
|
|
65
|
-
if response.is_a?(Net::HTTPServerError) && retries > 0
|
|
90
|
+
if retry_server_errors && response.is_a?(Net::HTTPServerError) && retries > 0
|
|
66
91
|
sleep(1)
|
|
67
|
-
return request(method, path, body, retries: retries - 1)
|
|
92
|
+
return request(method, path, body, retries: retries - 1, retry_server_errors: retry_server_errors)
|
|
68
93
|
end
|
|
69
94
|
|
|
70
95
|
unless response.is_a?(Net::HTTPSuccess)
|
|
71
|
-
|
|
96
|
+
# HttpStatusError, not a bare Error: the events flush branches on the status to
|
|
97
|
+
# decide whether the batch is worth keeping (#2456). Same message and same
|
|
98
|
+
# ancestry, so nothing that rescues Featureflip::Error changes behaviour.
|
|
99
|
+
raise HttpStatusError.new(response.code.to_i, path)
|
|
72
100
|
end
|
|
73
101
|
|
|
74
102
|
response
|
|
@@ -76,7 +104,7 @@ module Featureflip
|
|
|
76
104
|
Net::OpenTimeout, Net::ReadTimeout => e
|
|
77
105
|
raise if retries <= 0
|
|
78
106
|
sleep(1)
|
|
79
|
-
request(method, path, body, retries: retries - 1)
|
|
107
|
+
request(method, path, body, retries: retries - 1, retry_server_errors: retry_server_errors)
|
|
80
108
|
end
|
|
81
109
|
|
|
82
110
|
# Enum fields are strings on the wire. Ruby keeps whatever it is handed and the
|
|
@@ -165,6 +193,88 @@ module Featureflip
|
|
|
165
193
|
condition_logic: require_enum_string!(data["conditionLogic"], "segment.conditionLogic") || "And"
|
|
166
194
|
)
|
|
167
195
|
end
|
|
196
|
+
|
|
197
|
+
# Entity-level drop for enum values this SDK build cannot evaluate (#2402).
|
|
198
|
+
#
|
|
199
|
+
# `serve.type` and `conditionLogic` are the two enums that are BOTH carried on the
|
|
200
|
+
# wire as strings AND consulted by the evaluator, and each dispatches on a two-way
|
|
201
|
+
# branch with no third arm:
|
|
202
|
+
#
|
|
203
|
+
# serve.type == "Fixed" ... else ROLLOUT
|
|
204
|
+
# logic == "And" ... else ANY (OR)
|
|
205
|
+
#
|
|
206
|
+
# So an unrecognised value does not fail — it takes the ELSE arm. A segment
|
|
207
|
+
# carrying conditionLogic "Xor" evaluates as OR, so a segment meant to require ALL
|
|
208
|
+
# of its conditions matches ANY of them: the rule fails OPEN and over-targets.
|
|
209
|
+
#
|
|
210
|
+
# Neither obvious fix works. Tolerating the value — as an unknown flag.type is
|
|
211
|
+
# tolerated — IS that silent mis-evaluation; flag.type is safe to tolerate only
|
|
212
|
+
# because nothing evaluates it. Raising MalformedPayloadError would discard the
|
|
213
|
+
# whole payload, so one additive server change takes down every flag on a pinned
|
|
214
|
+
# client (the #2372/#2395 outage shape).
|
|
215
|
+
#
|
|
216
|
+
# So the containing entity goes instead. Dropping a segment leaves rules pointing
|
|
217
|
+
# at it dangling, which is safe: Evaluation::Evaluator already treats an
|
|
218
|
+
# unresolvable segment_key as no-match (#1459), so the cascade fails CLOSED. The
|
|
219
|
+
# engine-generated `f-segment-unresolvable` golden vector pins that.
|
|
220
|
+
#
|
|
221
|
+
# Scoped deliberately to a NON-EMPTY unrecognised value. An absent field already
|
|
222
|
+
# defaults to "And" above, and the missing-required-field axis is a separate
|
|
223
|
+
# concern that the SDKs deliberately disagree on; checking only values that are
|
|
224
|
+
# present and unrecognised keeps this change purely additive.
|
|
225
|
+
SERVE_TYPES = ["Fixed", "Rollout"].freeze
|
|
226
|
+
CONDITION_LOGIC = ["And", "Or"].freeze
|
|
227
|
+
|
|
228
|
+
def drop_unevaluable(entities, kind)
|
|
229
|
+
entities.reject do |entity|
|
|
230
|
+
reason = yield(entity)
|
|
231
|
+
next false unless reason
|
|
232
|
+
|
|
233
|
+
@config.logger&.warn(
|
|
234
|
+
"Featureflip: dropping #{kind} #{entity.key.inspect}: #{reason}. This SDK " \
|
|
235
|
+
"version may be older than the flag configuration; the rest of the " \
|
|
236
|
+
"configuration was applied."
|
|
237
|
+
)
|
|
238
|
+
true
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Why this flag cannot be evaluated, or nil if it can. A reason rather than a
|
|
243
|
+
# boolean so the diagnostic can name the field and the value actually received.
|
|
244
|
+
def unevaluable_flag_reason(flag)
|
|
245
|
+
reason = unevaluable_serve_reason(flag.fallthrough, "fallthrough")
|
|
246
|
+
return reason if reason
|
|
247
|
+
|
|
248
|
+
(flag.rules || []).each do |rule|
|
|
249
|
+
reason = unevaluable_serve_reason(rule.serve, "rule[#{rule.id}].serve")
|
|
250
|
+
return reason if reason
|
|
251
|
+
|
|
252
|
+
(rule.condition_groups || []).each do |group|
|
|
253
|
+
next if group.operator.nil? || group.operator.empty?
|
|
254
|
+
next if CONDITION_LOGIC.include?(group.operator)
|
|
255
|
+
|
|
256
|
+
return "rule[#{rule.id}].conditionGroup.operator #{group.operator.inspect} " \
|
|
257
|
+
"is not a condition logic this SDK version understands"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Why this segment cannot be evaluated, or nil if it can.
|
|
265
|
+
def unevaluable_segment_reason(segment)
|
|
266
|
+
logic = segment.condition_logic
|
|
267
|
+
return nil if logic.nil? || logic.empty? || CONDITION_LOGIC.include?(logic)
|
|
268
|
+
|
|
269
|
+
"conditionLogic #{logic.inspect} is not a condition logic this SDK version understands"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def unevaluable_serve_reason(serve, path)
|
|
273
|
+
type = serve&.type
|
|
274
|
+
return nil if type.nil? || type.empty? || SERVE_TYPES.include?(type)
|
|
275
|
+
|
|
276
|
+
"#{path}.type #{type.inspect} is not a serve type this SDK version understands"
|
|
277
|
+
end
|
|
168
278
|
end
|
|
169
279
|
end
|
|
170
280
|
end
|
|
@@ -241,25 +241,29 @@ module Featureflip
|
|
|
241
241
|
return unless @event_processor
|
|
242
242
|
|
|
243
243
|
context = normalize_context(context)
|
|
244
|
-
@event_processor.queue_event({
|
|
244
|
+
@event_processor.queue_event(compact_event({
|
|
245
245
|
type: "Custom",
|
|
246
246
|
flagKey: event_key,
|
|
247
|
-
userId: context
|
|
248
|
-
metadata: metadata
|
|
247
|
+
userId: resolve_event_user_id(context),
|
|
248
|
+
metadata: metadata,
|
|
249
249
|
timestamp: Time.now.utc.iso8601
|
|
250
|
-
})
|
|
250
|
+
}))
|
|
251
251
|
end
|
|
252
252
|
|
|
253
253
|
def identify(context)
|
|
254
254
|
return unless @event_processor
|
|
255
255
|
|
|
256
256
|
context = normalize_context(context)
|
|
257
|
-
|
|
257
|
+
# Strip both identity spellings so the id is carried once, at the top
|
|
258
|
+
# level, and not duplicated inside the attribute bag.
|
|
259
|
+
attributes = context.reject { |k, _| IDENTITY_KEYS.include?(k) }
|
|
260
|
+
@event_processor.queue_event(compact_event({
|
|
258
261
|
type: "Identify",
|
|
259
262
|
flagKey: "$identify",
|
|
260
|
-
userId: context
|
|
263
|
+
userId: resolve_event_user_id(context),
|
|
264
|
+
metadata: attributes,
|
|
261
265
|
timestamp: Time.now.utc.iso8601
|
|
262
|
-
})
|
|
266
|
+
}))
|
|
263
267
|
end
|
|
264
268
|
|
|
265
269
|
def flush
|
|
@@ -382,7 +386,10 @@ module Featureflip
|
|
|
382
386
|
@event_processor = Events::EventProcessor.new(
|
|
383
387
|
@http_client,
|
|
384
388
|
flush_interval: @config.flush_interval,
|
|
385
|
-
flush_batch_size: @config.flush_batch_size
|
|
389
|
+
flush_batch_size: @config.flush_batch_size,
|
|
390
|
+
# A dropped or re-queued batch must be visible: this fix keeps analytics that the
|
|
391
|
+
# edge briefly rejects, and it must not also hide the rejections themselves.
|
|
392
|
+
logger: @config.logger
|
|
386
393
|
)
|
|
387
394
|
@event_processor.start
|
|
388
395
|
end
|
|
@@ -417,16 +424,43 @@ module Featureflip
|
|
|
417
424
|
context.transform_keys(&:to_s)
|
|
418
425
|
end
|
|
419
426
|
|
|
427
|
+
# Both spellings of the identity are accepted on an event context; the
|
|
428
|
+
# canonical +user_id+ wins when a caller supplies both. The evaluator
|
|
429
|
+
# already aliases these for bucketing, so events have to as well or an
|
|
430
|
+
# alias caller gets every event attributed to nil.
|
|
431
|
+
IDENTITY_KEYS = %w[user_id userId].freeze
|
|
432
|
+
|
|
433
|
+
def resolve_event_user_id(context)
|
|
434
|
+
raw = context["user_id"]
|
|
435
|
+
raw = context["userId"] if raw.nil?
|
|
436
|
+
raw&.to_s
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# +userId+ and +metadata+ are optional on the wire. Omit them rather than
|
|
440
|
+
# sending nulls or empty bags, matching the other SDKs.
|
|
441
|
+
#
|
|
442
|
+
# +metadata+ is caller-supplied and untyped, so the emptiness check is
|
|
443
|
+
# guarded: a caller passing a non-collection must not take a NoMethodError
|
|
444
|
+
# into their request path just to record an analytics event.
|
|
445
|
+
def compact_event(event)
|
|
446
|
+
event.delete(:userId) if event[:userId].nil?
|
|
447
|
+
metadata = event[:metadata]
|
|
448
|
+
if metadata.nil? || (metadata.respond_to?(:empty?) && metadata.empty?)
|
|
449
|
+
event.delete(:metadata)
|
|
450
|
+
end
|
|
451
|
+
event
|
|
452
|
+
end
|
|
453
|
+
|
|
420
454
|
def record_evaluation(key, context, variation_key)
|
|
421
455
|
return unless @event_processor
|
|
422
456
|
|
|
423
|
-
@event_processor.queue_event({
|
|
457
|
+
@event_processor.queue_event(compact_event({
|
|
424
458
|
type: "Evaluation",
|
|
425
459
|
flagKey: key,
|
|
426
|
-
userId: context
|
|
460
|
+
userId: resolve_event_user_id(context),
|
|
427
461
|
variation: variation_key,
|
|
428
462
|
timestamp: Time.now.utc.iso8601
|
|
429
|
-
})
|
|
463
|
+
}))
|
|
430
464
|
end
|
|
431
465
|
|
|
432
466
|
# Fire the registered evaluation inspectors. Called once per variation call
|
data/lib/featureflip/version.rb
CHANGED
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.6.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-08-
|
|
11
|
+
date: 2026-08-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|