e-volv-logs 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/lib/evolve_logs/carriers.rb +60 -0
- data/lib/evolve_logs/client.rb +383 -0
- data/lib/evolve_logs/context.rb +69 -0
- data/lib/evolve_logs/flags/cache.rb +87 -0
- data/lib/evolve_logs/flags/client.rb +316 -0
- data/lib/evolve_logs/flags/context.rb +128 -0
- data/lib/evolve_logs/flags/delivery.rb +391 -0
- data/lib/evolve_logs/flags/exposures.rb +128 -0
- data/lib/evolve_logs/flags/js_values.rb +228 -0
- data/lib/evolve_logs/flags/kernel.rb +334 -0
- data/lib/evolve_logs/flags/options.rb +64 -0
- data/lib/evolve_logs/flags/regex_cache.rb +72 -0
- data/lib/evolve_logs/flags/sse.rb +42 -0
- data/lib/evolve_logs/flags.rb +65 -0
- data/lib/evolve_logs/http.rb +48 -0
- data/lib/evolve_logs/logger.rb +51 -0
- data/lib/evolve_logs/rack_middleware.rb +33 -0
- data/lib/evolve_logs/redact.rb +40 -0
- data/lib/evolve_logs/version.rb +5 -0
- data/lib/evolve_logs.rb +179 -0
- metadata +95 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvolveLogs
|
|
4
|
+
module Flags
|
|
5
|
+
# JavaScript value rules the Launch kernel depends on
|
|
6
|
+
# (packages/flags-kernel/PORTING.md). The kernel is TypeScript; where its
|
|
7
|
+
# answer depends on how JavaScript prints, parses or compares a value,
|
|
8
|
+
# this module reproduces JavaScript on purpose so a flag evaluates
|
|
9
|
+
# identically in Ruby and Node.
|
|
10
|
+
module JsValues
|
|
11
|
+
# The absent sentinel — JavaScript `undefined`, separate from nil.
|
|
12
|
+
MISSING = Object.new.freeze
|
|
13
|
+
|
|
14
|
+
# The exact set JavaScript's String.prototype.trim removes: WhiteSpace +
|
|
15
|
+
# LineTerminator (ECMA-262). Keep in sync with PORTING.md.
|
|
16
|
+
JS_WHITESPACE = " \t\n\v\f\r
"
|
|
17
|
+
DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/.freeze
|
|
18
|
+
RADIX = /\A0(?:[xX][0-9a-fA-F]+|[oO][0-7]+|[bB][01]+)\z/.freeze
|
|
19
|
+
ISO =
|
|
20
|
+
/\A(\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?([Zz]|[+-]\d{2}:?\d{2})?\z/
|
|
21
|
+
.freeze
|
|
22
|
+
SEMVER_PART = /\A\s*[+-]?\d+/.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def js_trim(s)
|
|
27
|
+
s.gsub(/\A[#{Regexp.escape(JS_WHITESPACE)}]+|[#{Regexp.escape(JS_WHITESPACE)}]+\z/o, "")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def number?(v)
|
|
31
|
+
v.is_a?(Integer) || v.is_a?(Float)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def js_type(v)
|
|
35
|
+
return "undefined" if v.equal?(MISSING)
|
|
36
|
+
return "object" if v.nil? || v.is_a?(Array) || v.is_a?(Hash)
|
|
37
|
+
return "boolean" if v.equal?(true) || v.equal?(false)
|
|
38
|
+
return "number" if number?(v)
|
|
39
|
+
return "string" if v.is_a?(String)
|
|
40
|
+
|
|
41
|
+
"object"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# js_number_string is ECMAScript Number::toString over Ruby's shortest
|
|
45
|
+
# round-trip Float#to_s ("1.0e+20", "0.1"): digits d (length k) and
|
|
46
|
+
# exponent n with x = 0.d × 10^n, then the ECMA digit-splitting rules.
|
|
47
|
+
def js_number_string(x)
|
|
48
|
+
x = x.to_f
|
|
49
|
+
return "NaN" if x.nan?
|
|
50
|
+
return(x.positive? ? "Infinity" : "-Infinity") if x.infinite?
|
|
51
|
+
return "0" if x.zero?
|
|
52
|
+
return "-#{js_number_string(-x)}" if x.negative?
|
|
53
|
+
|
|
54
|
+
mantissa, exponent = x.to_s.split("e")
|
|
55
|
+
exponent = exponent.nil? ? 0 : exponent.to_i
|
|
56
|
+
int_part, frac_part = mantissa.split(".")
|
|
57
|
+
frac_part ||= ""
|
|
58
|
+
raw = int_part + frac_part
|
|
59
|
+
n = int_part.length + exponent
|
|
60
|
+
stripped = raw.sub(/\A0+/, "")
|
|
61
|
+
n -= raw.length - stripped.length
|
|
62
|
+
d = stripped.sub(/0+\z/, "")
|
|
63
|
+
k = d.length
|
|
64
|
+
return d + ("0" * (n - k)) if k <= n && n <= 21
|
|
65
|
+
return "#{d[0, n]}.#{d[n..-1]}" if n.positive? && n <= 21
|
|
66
|
+
return "0.#{'0' * -n}#{d}" if n > -6 && n <= 0
|
|
67
|
+
|
|
68
|
+
e = n - 1
|
|
69
|
+
head = k == 1 ? d : "#{d[0]}.#{d[1..-1]}"
|
|
70
|
+
"#{head}e#{e >= 0 ? '+' : '-'}#{e.abs}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# js_string is JavaScript's String(v) over the values a context carries.
|
|
74
|
+
def js_string(v)
|
|
75
|
+
return "null" if v.nil?
|
|
76
|
+
return "true" if v.equal?(true)
|
|
77
|
+
return "false" if v.equal?(false)
|
|
78
|
+
return js_number_string(v) if number?(v)
|
|
79
|
+
return v if v.is_a?(String)
|
|
80
|
+
if v.is_a?(Array)
|
|
81
|
+
return v.map { |item| item.nil? || item.equal?(MISSING) ? "" : js_string(item) }.join(",")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
"[object Object]"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def strict_equals(a, b)
|
|
88
|
+
ta = js_type(a)
|
|
89
|
+
tb = js_type(b)
|
|
90
|
+
return false unless ta == tb
|
|
91
|
+
return a.to_f == b.to_f if ta == "number"
|
|
92
|
+
return a == b if ta == "string" || ta == "boolean"
|
|
93
|
+
|
|
94
|
+
ta == "undefined"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# loose_equals is JavaScript `==` over the scalars a context carries
|
|
98
|
+
# (PORTING.md): strict equality first, then string comparison across
|
|
99
|
+
# differing types.
|
|
100
|
+
def loose_equals(a, b)
|
|
101
|
+
return true if a.nil? && b.nil?
|
|
102
|
+
return true if strict_equals(a, b)
|
|
103
|
+
return false if a.nil? || a.equal?(MISSING) || b.nil? || b.equal?(MISSING)
|
|
104
|
+
return false if js_type(a) == js_type(b)
|
|
105
|
+
|
|
106
|
+
js_string(a) == js_string(b)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# as_number is JavaScript Number(v) for the value shapes a context
|
|
110
|
+
# carries: decimal strings, 0x/0o/0b radices; booleans and the rest
|
|
111
|
+
# refuse. Never raises.
|
|
112
|
+
def as_number(v)
|
|
113
|
+
return nil if v.equal?(true) || v.equal?(false)
|
|
114
|
+
return(v.to_f.finite? ? v.to_f : nil) if number?(v)
|
|
115
|
+
return nil unless v.is_a?(String)
|
|
116
|
+
|
|
117
|
+
s = js_trim(v)
|
|
118
|
+
return nil if s.empty?
|
|
119
|
+
if DECIMAL.match?(s)
|
|
120
|
+
# Ruby's Float() refuses "5." (JavaScript allows it) and accepts
|
|
121
|
+
# ".5" already; normalise both spellings.
|
|
122
|
+
normalised = s.end_with?(".") ? "#{s}0" : s
|
|
123
|
+
if normalised.start_with?("+.", "-.")
|
|
124
|
+
normalised = "#{normalised[0]}0#{normalised[1..-1]}"
|
|
125
|
+
elsif normalised.start_with?(".")
|
|
126
|
+
normalised = "0#{normalised}"
|
|
127
|
+
end
|
|
128
|
+
f = Float(normalised)
|
|
129
|
+
return f.finite? ? f : nil
|
|
130
|
+
end
|
|
131
|
+
return Integer(s, 0).to_f if RADIX.match?(s) # Integer("0b11", 0) accepts 0x/0o/0b
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
rescue ArgumentError, TypeError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# parse_iso_date is the one date grammar every SDK parses identically
|
|
139
|
+
# (src/dates.ts): ISO-8601 calendar date, optional time/fraction/offset,
|
|
140
|
+
# a missing offset is UTC, year 0000 is refused.
|
|
141
|
+
def parse_iso_date(s)
|
|
142
|
+
m = ISO.match(js_trim(s.to_s))
|
|
143
|
+
return nil unless m
|
|
144
|
+
|
|
145
|
+
y = m[1].to_i
|
|
146
|
+
mo = m[2].to_i
|
|
147
|
+
d = m[3].to_i
|
|
148
|
+
h = m[4].nil? ? 0 : m[4].to_i
|
|
149
|
+
mi = m[5].nil? ? 0 : m[5].to_i
|
|
150
|
+
sec = m[6].nil? ? 0 : m[6].to_i
|
|
151
|
+
frac = m[7] || ""
|
|
152
|
+
zone = m[8]
|
|
153
|
+
return nil if y.zero?
|
|
154
|
+
return nil if h > 23 || mi > 59 || sec > 59
|
|
155
|
+
|
|
156
|
+
# Time.utc normalises overflow instead of raising (2026-02-30 →
|
|
157
|
+
# March 2); the round-trip check below refuses it like every port.
|
|
158
|
+
moment = Time.utc(y, mo, d, h, mi, sec)
|
|
159
|
+
return nil unless moment.year == y && moment.month == mo && moment.day == d
|
|
160
|
+
|
|
161
|
+
millis = (frac + "000")[0, 3].to_i
|
|
162
|
+
offset = 0
|
|
163
|
+
if zone && zone != "Z" && zone != "z"
|
|
164
|
+
digits = zone[1..-1].delete(":")
|
|
165
|
+
offset = (zone[0] == "-" ? -1 : 1) * (digits[0, 2].to_i * 60 + digits[2, 2].to_i)
|
|
166
|
+
end
|
|
167
|
+
(moment.to_i * 1000 + millis - offset * 60_000).to_f
|
|
168
|
+
rescue ArgumentError, TypeError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def as_date(v)
|
|
173
|
+
return nil if v.equal?(true) || v.equal?(false)
|
|
174
|
+
if number?(v)
|
|
175
|
+
f = v.to_f
|
|
176
|
+
return f.finite? ? f : nil
|
|
177
|
+
end
|
|
178
|
+
if v.is_a?(String) && !js_trim(v).empty?
|
|
179
|
+
parsed = parse_iso_date(v)
|
|
180
|
+
return parsed unless parsed.nil?
|
|
181
|
+
|
|
182
|
+
return as_number(v)
|
|
183
|
+
end
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# semver_parts: strip one leading lowercase v, take the text before the
|
|
188
|
+
# first "-", split on ".", each part reads its leading signed digits
|
|
189
|
+
# (else 0). Missing parts count as 0.
|
|
190
|
+
def semver_parts(v)
|
|
191
|
+
v = v.to_s
|
|
192
|
+
v = v[1..-1] if v.start_with?("v")
|
|
193
|
+
v.split("-").first.split(".").map do |part|
|
|
194
|
+
m = SEMVER_PART.match(part)
|
|
195
|
+
m ? m[0].to_i : 0
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def semver_compare(a, b)
|
|
200
|
+
left = semver_parts(a)
|
|
201
|
+
right = semver_parts(b)
|
|
202
|
+
[left.length, right.length].max.times do |i|
|
|
203
|
+
x = i < left.length ? left[i] : 0
|
|
204
|
+
y = i < right.length ? right[i] : 0
|
|
205
|
+
diff = x - y
|
|
206
|
+
return(diff.negative? ? -1 : 1) unless diff.zero?
|
|
207
|
+
end
|
|
208
|
+
0
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# stringify is a bucketing subject: a stable string, or nothing.
|
|
212
|
+
def stringify(v)
|
|
213
|
+
return "true" if v.equal?(true)
|
|
214
|
+
return "false" if v.equal?(false)
|
|
215
|
+
return(v.empty? ? nil : v) if v.is_a?(String)
|
|
216
|
+
return js_number_string(v) if number?(v) && v.to_f.finite?
|
|
217
|
+
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# utf16_length counts UTF-16 code units — the 1,024 match limit is
|
|
222
|
+
# counted in these, so a code point above U+FFFF counts 2.
|
|
223
|
+
def utf16_length(s)
|
|
224
|
+
s.each_char.sum { |ch| ch.ord > 0xFFFF ? 2 : 1 }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
|
|
5
|
+
require "evolve_logs/flags/js_values"
|
|
6
|
+
require "evolve_logs/flags/context"
|
|
7
|
+
require "evolve_logs/flags/regex_cache"
|
|
8
|
+
|
|
9
|
+
module EvolveLogs
|
|
10
|
+
module Flags
|
|
11
|
+
# The answer to one evaluation — the served value, the chosen variant,
|
|
12
|
+
# and why (docs/LAUNCH-SDK.md §3).
|
|
13
|
+
Evaluation = Struct.new(:value, :variant, :reason)
|
|
14
|
+
|
|
15
|
+
# Port of packages/flags-kernel/src/evaluate.ts; PORTING.md has the port
|
|
16
|
+
# map. Evaluation is synchronous, never raises and never performs I/O —
|
|
17
|
+
# a malformed ruleset answers the caller's fallback with reason ERROR.
|
|
18
|
+
module Kernel
|
|
19
|
+
RULESET_VERSION = 2
|
|
20
|
+
MAX_PREREQUISITE_DEPTH = 16
|
|
21
|
+
MAX_MATCH_ATTRIBUTE_LENGTH = 1024
|
|
22
|
+
BUCKET_SPACE = 10_000
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# bucket is the deterministic rollout bucket for (flag, subject):
|
|
27
|
+
# sha1, first eight hex digits, modulo the bucket space (src/bucket.ts).
|
|
28
|
+
def bucket(flag_key, subject, salt = "")
|
|
29
|
+
source = salt && !salt.empty? ? "#{flag_key}:#{salt}:#{subject}" : "#{flag_key}:#{subject}"
|
|
30
|
+
Digest::SHA1.hexdigest(source.encode(Encoding::UTF_8))[0, 8].to_i(16) % BUCKET_SPACE
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# evaluate resolves one flag for one subject. Precedence, first match
|
|
34
|
+
# wins: not found → killed → off → unmet prerequisite → individual
|
|
35
|
+
# target → the first matching rule (its rollout, else its variant) →
|
|
36
|
+
# the default rollout, else the default variant, else the fallback when
|
|
37
|
+
# the variant named does not exist.
|
|
38
|
+
def evaluate(ruleset, flag_key, fallback, context = {}, is_member: nil, resolved: nil, depth: 0)
|
|
39
|
+
return Evaluation.new(fallback, nil, "ERROR") if depth > MAX_PREREQUISITE_DEPTH
|
|
40
|
+
|
|
41
|
+
flag = own(ruleset.is_a?(Hash) ? ruleset["flags"] : nil, flag_key)
|
|
42
|
+
return Evaluation.new(fallback, nil, "FLAG_NOT_FOUND") if flag.nil? || flag == false
|
|
43
|
+
|
|
44
|
+
contexts = resolved || Context.resolve(context.nil? ? {} : context)
|
|
45
|
+
segments = ruleset.is_a?(Hash) ? (ruleset["segments"] || {}) : {}
|
|
46
|
+
targeting = flag["targeting"]
|
|
47
|
+
|
|
48
|
+
serve = lambda do |variant_key, reason|
|
|
49
|
+
found = variant_value(flag, variant_key)
|
|
50
|
+
found.nil? ? Evaluation.new(fallback, nil, "ERROR") : Evaluation.new(found[0], found[1], reason)
|
|
51
|
+
end
|
|
52
|
+
served_off = lambda do |reason|
|
|
53
|
+
# offVariant falls back to defaultVariant when absent or null (??).
|
|
54
|
+
off = targeting["offVariant"]
|
|
55
|
+
off = targeting["defaultVariant"] if off.nil?
|
|
56
|
+
serve.call(off, reason)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return served_off.call("KILLED") if targeting["killed"]
|
|
60
|
+
return served_off.call("OFF") if targeting["on"] == false
|
|
61
|
+
|
|
62
|
+
(targeting["prerequisites"] || []).each do |prerequisite|
|
|
63
|
+
upstream = evaluate(ruleset, prerequisite["flag"], nil, context,
|
|
64
|
+
is_member: is_member, resolved: contexts, depth: depth + 1)
|
|
65
|
+
if upstream.variant.nil? || !(prerequisite["variants"] || []).include?(upstream.variant)
|
|
66
|
+
return served_off.call("PREREQUISITE_FAILED")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
(targeting["targets"] || []).each do |target|
|
|
71
|
+
context_kind = target.key?("contextKind") ? target["contextKind"] : JsValues::MISSING
|
|
72
|
+
key = Context.key_of_kind(contexts, context_kind)
|
|
73
|
+
if !key.nil? && (target["keys"] || []).include?(key)
|
|
74
|
+
return serve.call(target["variant"], "TARGET_MATCH")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
(targeting["rules"] || []).each_with_index do |rule, i|
|
|
79
|
+
next unless match_rule(rule, contexts, segments, is_member)
|
|
80
|
+
|
|
81
|
+
rollout = rule["rollout"]
|
|
82
|
+
if rollout.is_a?(Array) && !rollout.empty?
|
|
83
|
+
bucket_by = rule.key?("bucketBy") ? rule["bucketBy"] : JsValues::MISSING
|
|
84
|
+
rollout_kind = rule.key?("rolloutContextKind") ? rule["rolloutContextKind"] : JsValues::MISSING
|
|
85
|
+
subject, misconfigured = rollout_subject(contexts, bucket_by, rollout_kind)
|
|
86
|
+
return served_off.call("ERROR") if misconfigured
|
|
87
|
+
|
|
88
|
+
unless subject.nil?
|
|
89
|
+
salt = rule["id"].nil? ? "" : rule["id"]
|
|
90
|
+
found = variant_value(flag, slice_for(rollout, flag_key, salt, subject))
|
|
91
|
+
return Evaluation.new(found[0], found[1], "ROLLOUT") unless found.nil?
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
found = variant_value(flag, rule["variant"])
|
|
95
|
+
return Evaluation.new(found[0], found[1], "RULE:#{i}") unless found.nil?
|
|
96
|
+
end
|
|
97
|
+
# No identity to bucket on, or a rule naming a variant the flag does
|
|
98
|
+
# not have: fall through to the default rather than to the next
|
|
99
|
+
# rule — the rule DID match, and skipping on would serve a variant
|
|
100
|
+
# the author did not intend for this subject.
|
|
101
|
+
break
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
default_rollout = targeting["defaultRollout"]
|
|
105
|
+
if default_rollout.is_a?(Array) && !default_rollout.empty?
|
|
106
|
+
bucket_by = targeting.key?("defaultBucketBy") ? targeting["defaultBucketBy"] : JsValues::MISSING
|
|
107
|
+
rollout_kind = targeting.key?("defaultRolloutContextKind") ? targeting["defaultRolloutContextKind"] : JsValues::MISSING
|
|
108
|
+
subject, misconfigured = rollout_subject(contexts, bucket_by, rollout_kind)
|
|
109
|
+
return served_off.call("ERROR") if misconfigured
|
|
110
|
+
|
|
111
|
+
unless subject.nil?
|
|
112
|
+
found = variant_value(flag, slice_for(default_rollout, flag_key, "default", subject))
|
|
113
|
+
return Evaluation.new(found[0], found[1], "ROLLOUT") unless found.nil?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
serve.call(targeting["defaultVariant"], "DEFAULT")
|
|
118
|
+
rescue StandardError, SystemStackError
|
|
119
|
+
Evaluation.new(fallback, nil, "ERROR")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# -- matching ----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
# match_operator answers one clause operator against the actual value.
|
|
125
|
+
# An operator this build does not know never matches: refusing is the
|
|
126
|
+
# safe direction for a rule nobody understands.
|
|
127
|
+
def match_operator(op, actual, values)
|
|
128
|
+
is_str = actual.is_a?(String)
|
|
129
|
+
case op
|
|
130
|
+
when "exists"
|
|
131
|
+
!actual.equal?(JsValues::MISSING) && !actual.nil?
|
|
132
|
+
when "notExists"
|
|
133
|
+
actual.equal?(JsValues::MISSING) || actual.nil?
|
|
134
|
+
when "eq", "in"
|
|
135
|
+
values.any? { |v| JsValues.loose_equals(actual, v) }
|
|
136
|
+
when "neq", "notIn"
|
|
137
|
+
!values.any? { |v| JsValues.loose_equals(actual, v) }
|
|
138
|
+
when "contains"
|
|
139
|
+
is_str && values.any? { |v| actual.include?(JsValues.js_string(v)) }
|
|
140
|
+
when "notContains"
|
|
141
|
+
!is_str || !values.any? { |v| actual.include?(JsValues.js_string(v)) }
|
|
142
|
+
when "startsWith"
|
|
143
|
+
is_str && values.any? { |v| actual.start_with?(JsValues.js_string(v)) }
|
|
144
|
+
when "endsWith"
|
|
145
|
+
is_str && values.any? { |v| actual.end_with?(JsValues.js_string(v)) }
|
|
146
|
+
when "matches"
|
|
147
|
+
is_str &&
|
|
148
|
+
JsValues.utf16_length(actual) <= MAX_MATCH_ATTRIBUTE_LENGTH &&
|
|
149
|
+
values.any? { |v| search(v, actual) }
|
|
150
|
+
when "notMatches"
|
|
151
|
+
# A pattern that will not compile matches nothing, so its negation
|
|
152
|
+
# matches everything — but an over-long attribute is refused
|
|
153
|
+
# outright rather than folded into that fallback.
|
|
154
|
+
if is_str && JsValues.utf16_length(actual) > MAX_MATCH_ATTRIBUTE_LENGTH
|
|
155
|
+
return false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
!is_str || !values.any? { |v| search(v, actual) }
|
|
159
|
+
when "gt", "gte", "lt", "lte"
|
|
160
|
+
values.any? { |v| compare_numeric(actual, v, op) }
|
|
161
|
+
when "before", "after"
|
|
162
|
+
a = JsValues.as_date(actual)
|
|
163
|
+
return false if a.nil?
|
|
164
|
+
|
|
165
|
+
values.any? do |v|
|
|
166
|
+
b = JsValues.as_date(v)
|
|
167
|
+
!b.nil? && (op == "before" ? a < b : a > b)
|
|
168
|
+
end
|
|
169
|
+
when "semverEq", "semverGt", "semverLt", "semverGte", "semverLte"
|
|
170
|
+
return false unless is_str
|
|
171
|
+
|
|
172
|
+
values.any? do |v|
|
|
173
|
+
comparison = JsValues.semver_compare(actual, JsValues.js_string(v))
|
|
174
|
+
case op
|
|
175
|
+
when "semverEq" then comparison.zero?
|
|
176
|
+
when "semverGt" then comparison.positive?
|
|
177
|
+
when "semverLt" then comparison.negative?
|
|
178
|
+
when "semverGte" then comparison >= 0
|
|
179
|
+
else comparison <= 0
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# match_clauses: every clause must match; absent clauses match
|
|
188
|
+
# everything.
|
|
189
|
+
def match_clauses(clauses, contexts)
|
|
190
|
+
(clauses || []).each do |clause|
|
|
191
|
+
kind = clause.key?("contextKind") ? clause["contextKind"] : JsValues::MISSING
|
|
192
|
+
actual = Context.attribute_of_kind(contexts, kind, clause["attribute"])
|
|
193
|
+
return false unless match_operator(clause["op"], actual, clause["values"] || [])
|
|
194
|
+
end
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# match_segment: excluded beats included beats rules — the order people
|
|
199
|
+
# expect from an allow list with an override. A rule carrying a numeric
|
|
200
|
+
# weight matches only a deterministic share of what it otherwise would,
|
|
201
|
+
# bucketed on the segment key.
|
|
202
|
+
def match_segment(segment, contexts, is_member)
|
|
203
|
+
return false unless segment.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
kind = segment.key?("contextKind") ? segment["contextKind"] : JsValues::MISSING
|
|
206
|
+
kind = Context.kind_or_default(kind)
|
|
207
|
+
key = Context.key_of_kind(contexts, kind)
|
|
208
|
+
|
|
209
|
+
if segment["external"]
|
|
210
|
+
# The lists are too large to ship; "not a member" is the narrower
|
|
211
|
+
# answer when no callback can say.
|
|
212
|
+
return false if is_member.nil? || key.nil?
|
|
213
|
+
return true if is_member.call(segment["key"], kind, key)
|
|
214
|
+
elsif !key.nil?
|
|
215
|
+
return false if (segment["excluded"] || []).include?(key)
|
|
216
|
+
return true if (segment["included"] || []).include?(key)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
(segment["rules"] || []).each do |rule|
|
|
220
|
+
next unless match_clauses(rule["clauses"], contexts)
|
|
221
|
+
|
|
222
|
+
weight = rule["weight"]
|
|
223
|
+
return true unless weight.is_a?(Numeric)
|
|
224
|
+
|
|
225
|
+
# bucketBy present (even null) means "bucket on this attribute".
|
|
226
|
+
subject = if rule.key?("bucketBy")
|
|
227
|
+
JsValues.stringify(Context.attribute_of_kind(contexts, kind, rule["bucketBy"]))
|
|
228
|
+
else
|
|
229
|
+
key
|
|
230
|
+
end
|
|
231
|
+
next if subject.nil?
|
|
232
|
+
|
|
233
|
+
return true if bucket(segment["key"], subject, "segment") < clamp_weight(weight)
|
|
234
|
+
end
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# segment_conditions normalises the two shapes Rule.segments carries:
|
|
239
|
+
# a bare string list (pre-negation rulesets) means "in all of these".
|
|
240
|
+
def segment_conditions(segments)
|
|
241
|
+
return [] if segments.nil? || segments.empty?
|
|
242
|
+
return segments.map { |k| { "keys" => [k] } } if segments[0].is_a?(String)
|
|
243
|
+
|
|
244
|
+
segments
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def match_rule(rule, contexts, segments, is_member)
|
|
248
|
+
return false unless match_clauses(rule["clauses"], contexts)
|
|
249
|
+
|
|
250
|
+
segment_conditions(rule["segments"]).each do |condition|
|
|
251
|
+
in_any = (condition["keys"] || []).any? do |k|
|
|
252
|
+
match_segment(own(segments, k), contexts, is_member)
|
|
253
|
+
end
|
|
254
|
+
negate = condition["negate"]
|
|
255
|
+
return false if negate ? in_any : !in_any
|
|
256
|
+
end
|
|
257
|
+
true
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# -- rollouts ------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
# slice_for: which slice of a rollout this subject lands in. Weights
|
|
263
|
+
# summing under the bucket space serve the last slice rather than
|
|
264
|
+
# nothing — a mis-summed rollout degrades instead of failing.
|
|
265
|
+
def slice_for(slices, flag_key, salt, subject)
|
|
266
|
+
point = bucket(flag_key, subject, salt)
|
|
267
|
+
cursor = 0
|
|
268
|
+
slices.each do |s|
|
|
269
|
+
cursor += s["weight"]
|
|
270
|
+
return s["variant"] if point < cursor
|
|
271
|
+
end
|
|
272
|
+
slices.empty? ? nil : slices[-1]["variant"]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# rollout_subject: the subject a rollout buckets on. No context key at
|
|
276
|
+
# all is an anonymous caller — the rollout declines and lets the
|
|
277
|
+
# default serve. An explicit bucketBy naming an attribute the context
|
|
278
|
+
# does not carry is a misconfigured rollout: serving anything at all
|
|
279
|
+
# would measure against an identity nobody chose.
|
|
280
|
+
def rollout_subject(contexts, bucket_by, context_kind)
|
|
281
|
+
kind = Context.kind_or_default(context_kind)
|
|
282
|
+
if bucket_by.equal?(JsValues::MISSING)
|
|
283
|
+
return [Context.key_of_kind(contexts, kind), false]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
subject = JsValues.stringify(Context.attribute_of_kind(contexts, kind, bucket_by))
|
|
287
|
+
[subject, subject.nil?]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def variant_value(flag, variant_key)
|
|
291
|
+
return nil if variant_key.nil? || variant_key == false || variant_key == ""
|
|
292
|
+
|
|
293
|
+
flag["definition"]["variants"].each do |v|
|
|
294
|
+
return [v["value"], v["key"]] if v["key"] == variant_key
|
|
295
|
+
end
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def clamp_weight(weight)
|
|
300
|
+
return 0 unless weight.is_a?(Numeric) && weight.to_f.finite?
|
|
301
|
+
|
|
302
|
+
[[weight, 0].max, BUCKET_SPACE].min
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# search runs one `matches` pattern: compiled once, cached, never
|
|
306
|
+
# raising — a pattern that fails to compile matches nothing.
|
|
307
|
+
def search(pattern, actual)
|
|
308
|
+
compiled = RegexCache.compile(JsValues.js_string(pattern))
|
|
309
|
+
!compiled.nil? && compiled.match?(actual)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def compare_numeric(actual, expected, op)
|
|
313
|
+
a = JsValues.as_number(actual)
|
|
314
|
+
b = JsValues.as_number(expected)
|
|
315
|
+
return false if a.nil? || b.nil?
|
|
316
|
+
|
|
317
|
+
case op
|
|
318
|
+
when "gt" then a > b
|
|
319
|
+
when "gte" then a >= b
|
|
320
|
+
when "lt" then a < b
|
|
321
|
+
else a <= b
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# own reads a record through its own keys only: a flag or segment named
|
|
326
|
+
# like a prototype member must stay "not found". JSON.parse-built hashes
|
|
327
|
+
# are own-key by construction; the key? guard keeps it true for any
|
|
328
|
+
# hash, default-proc or not.
|
|
329
|
+
def own(record, key)
|
|
330
|
+
record.is_a?(Hash) && record.key?(key) ? record[key] : nil
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvolveLogs
|
|
4
|
+
module Flags
|
|
5
|
+
# Launch flags client options (docs/LAUNCH-SDK.md §4, Ruby spellings).
|
|
6
|
+
#
|
|
7
|
+
# `cache` is nil for the default cache dir, false to disable caching, or
|
|
8
|
+
# a directory path. `exposures` carries the §7 knobs: enabled,
|
|
9
|
+
# sample_rate, dedupe_window_seconds, send_attributes.
|
|
10
|
+
class Options
|
|
11
|
+
attr_reader :enabled, :url, :mode, :poll_interval_seconds, :cache, :bootstrap,
|
|
12
|
+
:exposures, :private_attributes, :stale_after_seconds
|
|
13
|
+
|
|
14
|
+
# Exposure option keys, snake_case (built from a Hash that may use
|
|
15
|
+
# strings or symbols).
|
|
16
|
+
EXPOSURE_KEYS = %i[enabled sample_rate dedupe_window_seconds send_attributes].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(enabled: true, url: nil, mode: "stream", poll_interval_seconds: 30,
|
|
19
|
+
cache: nil, bootstrap: nil, exposures: {}, private_attributes: [],
|
|
20
|
+
stale_after_seconds: 300)
|
|
21
|
+
@enabled = enabled
|
|
22
|
+
@url = url
|
|
23
|
+
@mode = mode
|
|
24
|
+
@poll_interval_seconds = poll_interval_seconds
|
|
25
|
+
@cache = cache
|
|
26
|
+
@bootstrap = bootstrap
|
|
27
|
+
@exposures = self.class.normalize_exposures(exposures)
|
|
28
|
+
@private_attributes = private_attributes || []
|
|
29
|
+
@stale_after_seconds = stale_after_seconds
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# build normalises the `flags:` argument of EvolveLogs.init /
|
|
33
|
+
# EvolveLogs::Client.new: nil, an Options, or a Hash with string or
|
|
34
|
+
# symbol keys. Unknown keys are ignored so a shared config hash never
|
|
35
|
+
# raises.
|
|
36
|
+
def self.build(value)
|
|
37
|
+
return new if value.nil?
|
|
38
|
+
return value if value.is_a?(Options)
|
|
39
|
+
|
|
40
|
+
hash = {}
|
|
41
|
+
if value.is_a?(Hash)
|
|
42
|
+
value.each do |k, v|
|
|
43
|
+
key = k.to_s.to_sym
|
|
44
|
+
hash[key] = v if %i[enabled url mode poll_interval_seconds cache bootstrap
|
|
45
|
+
exposures private_attributes stale_after_seconds].include?(key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
hash[:exposures] = normalize_exposures(hash[:exposures])
|
|
49
|
+
new(**hash)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.normalize_exposures(value)
|
|
53
|
+
return {} unless value.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
out = {}
|
|
56
|
+
value.each do |k, v|
|
|
57
|
+
key = k.to_s.to_sym
|
|
58
|
+
out[key] = v if EXPOSURE_KEYS.include?(key)
|
|
59
|
+
end
|
|
60
|
+
out
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvolveLogs
|
|
4
|
+
module Flags
|
|
5
|
+
# Port of flags-kernel/src/regex.ts on Onigmo: compile once, cache ≤ 256
|
|
6
|
+
# (FIFO eviction), a pattern that fails to compile never matches.
|
|
7
|
+
#
|
|
8
|
+
# JavaScript (no m flag) anchors ^ and $ to the whole input; Ruby anchors
|
|
9
|
+
# them to lines — translate() rewrites them outside character classes so
|
|
10
|
+
# the same pattern answers the same question in both engines. `\d`, `\w`
|
|
11
|
+
# and `\b` are already ASCII in Ruby, as in JavaScript.
|
|
12
|
+
module RegexCache
|
|
13
|
+
LIMIT = 256
|
|
14
|
+
@cache = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# compile returns the Regexp or nil; nil is cached too — a pattern
|
|
19
|
+
# that does not compile does not compile again.
|
|
20
|
+
def compile(pattern)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
return @cache[pattern] if @cache.key?(pattern)
|
|
23
|
+
|
|
24
|
+
compiled = begin
|
|
25
|
+
Regexp.new(translate(pattern))
|
|
26
|
+
rescue RegexpError, ArgumentError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
@cache.delete(@cache.keys.first) if @cache.size >= LIMIT
|
|
30
|
+
@cache[pattern] = compiled
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# translate rewrites JavaScript anchors to Ruby's input anchors. A `^`
|
|
37
|
+
# directly after `[` is a negation and stays; escaped characters pass
|
|
38
|
+
# through; `(?` constructs are untouched (lookahead is Onigmo-valid,
|
|
39
|
+
# and the write-time portability check refuses it before a ruleset
|
|
40
|
+
# can carry it).
|
|
41
|
+
def translate(pattern)
|
|
42
|
+
out = +""
|
|
43
|
+
in_class = false
|
|
44
|
+
i = 0
|
|
45
|
+
while i < pattern.length
|
|
46
|
+
ch = pattern[i]
|
|
47
|
+
if ch == "\\" && i + 1 < pattern.length
|
|
48
|
+
out << pattern[i, 2]
|
|
49
|
+
i += 2
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
if in_class
|
|
53
|
+
in_class = false if ch == "]"
|
|
54
|
+
out << ch
|
|
55
|
+
elsif ch == "["
|
|
56
|
+
in_class = true
|
|
57
|
+
out << ch
|
|
58
|
+
elsif ch == "^"
|
|
59
|
+
out << "\\A"
|
|
60
|
+
elsif ch == "$"
|
|
61
|
+
out << "\\z"
|
|
62
|
+
else
|
|
63
|
+
out << ch
|
|
64
|
+
end
|
|
65
|
+
i += 1
|
|
66
|
+
end
|
|
67
|
+
out
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|