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,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvolveLogs
|
|
4
|
+
# TraceContext is one position in a trace. Ruby has no AsyncLocalStorage or
|
|
5
|
+
# contextvars; Thread.current[] is fiber-local, so the context flows into
|
|
6
|
+
# fibers spawned under the current thread and never leaks across threads.
|
|
7
|
+
TraceContext = Struct.new(:trace_id, :span_id, :parent_span_id) do
|
|
8
|
+
def root?
|
|
9
|
+
parent_span_id.nil? || parent_span_id.empty?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module Context
|
|
14
|
+
KEY = :__evolve_logs_trace_context__
|
|
15
|
+
|
|
16
|
+
TRACEPARENT_RE = /\A\s*00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\s*\z/.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def current
|
|
21
|
+
Thread.current[KEY]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# with installs ctx for the duration of the block and restores the
|
|
25
|
+
# previous context afterwards — including when the block raises.
|
|
26
|
+
def with(ctx)
|
|
27
|
+
previous = Thread.current[KEY]
|
|
28
|
+
Thread.current[KEY] = ctx
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
Thread.current[KEY] = previous
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def new_root
|
|
35
|
+
TraceContext.new(SecureRandom.hex(16), SecureRandom.hex(8), nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# child returns the next hop: the parent's trace id, a new span id and
|
|
39
|
+
# the parent's span id as parent. A nil parent starts a fresh root trace.
|
|
40
|
+
def child(parent)
|
|
41
|
+
return new_root if parent.nil?
|
|
42
|
+
TraceContext.new(parent.trace_id, SecureRandom.hex(8), parent.span_id)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# parse_traceparent parses a W3C traceparent header, returning nil when
|
|
46
|
+
# it is absent, malformed, or carries all-zero ids.
|
|
47
|
+
def parse_traceparent(header)
|
|
48
|
+
return nil if header.nil?
|
|
49
|
+
match = TRACEPARENT_RE.match(header.to_s.downcase)
|
|
50
|
+
return nil unless match
|
|
51
|
+
return nil if match[1].delete("0").empty? || match[2].delete("0").empty?
|
|
52
|
+
TraceContext.new(match[1], match[2], nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# hop returns the trace context for the next hop of the trace named by a
|
|
56
|
+
# traceparent header. A malformed or absent header starts a fresh root
|
|
57
|
+
# trace, so a producer that sends nothing still yields a trace of its own
|
|
58
|
+
# rather than an error.
|
|
59
|
+
def hop(header)
|
|
60
|
+
child(parse_traceparent(header))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def traceparent
|
|
64
|
+
tc = current
|
|
65
|
+
return nil if tc.nil?
|
|
66
|
+
"00-#{tc.trace_id}-#{tc.span_id}-01"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
require "evolve_logs/flags/kernel"
|
|
7
|
+
|
|
8
|
+
module EvolveLogs
|
|
9
|
+
module Flags
|
|
10
|
+
# Ruleset cache: one envelope file per key, atomic writes (contract §6).
|
|
11
|
+
# The file name is a sha1 of the key — the key itself never appears on
|
|
12
|
+
# disk. Everything rescues to nil: caching is best-effort.
|
|
13
|
+
module Cache
|
|
14
|
+
MAX_CACHE_BYTES = 10 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def cache_name(key)
|
|
19
|
+
"evolve-flags-#{Digest::SHA1.hexdigest(key.to_s.encode(Encoding::UTF_8))[0, 12]}.json"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def default_dir
|
|
23
|
+
File.join(Dir.tmpdir, "evolve-flags")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# parse_envelope applies the §6 reject rules to raw envelope JSON.
|
|
27
|
+
def parse_envelope(raw, kind = "ruleset")
|
|
28
|
+
return nil if raw.nil? || raw.empty? || raw.bytesize > MAX_CACHE_BYTES
|
|
29
|
+
|
|
30
|
+
env = JSON.parse(raw)
|
|
31
|
+
return nil unless env.is_a?(Hash)
|
|
32
|
+
return nil unless env["formatVersion"] == 1
|
|
33
|
+
return nil unless env["kind"] == kind
|
|
34
|
+
return nil unless env["environmentId"].is_a?(String)
|
|
35
|
+
return nil unless env["etag"].is_a?(String)
|
|
36
|
+
|
|
37
|
+
version = env["rulesetVersion"]
|
|
38
|
+
return nil unless version.is_a?(Numeric)
|
|
39
|
+
return nil if version > Kernel::RULESET_VERSION
|
|
40
|
+
return nil unless env["payload"].is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
env
|
|
43
|
+
rescue JSON::ParserError, TypeError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def read(dir, key)
|
|
48
|
+
path = File.join(dir, cache_name(key))
|
|
49
|
+
return nil unless File.file?(path)
|
|
50
|
+
return nil if File.size(path) > MAX_CACHE_BYTES
|
|
51
|
+
|
|
52
|
+
parse_envelope(File.binread(path).force_encoding(Encoding::UTF_8))
|
|
53
|
+
rescue SystemCallError, IOError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# write persists the ruleset envelope atomically (temp file + rename,
|
|
58
|
+
# 0600); a payload over 10 MB is not cached. Never raises.
|
|
59
|
+
def write(dir, key, ruleset)
|
|
60
|
+
body = JSON.generate(
|
|
61
|
+
"formatVersion" => 1,
|
|
62
|
+
"kind" => "ruleset",
|
|
63
|
+
"environmentId" => ruleset["environmentId"],
|
|
64
|
+
"rulesetVersion" => ruleset["rulesetVersion"],
|
|
65
|
+
"etag" => ruleset["etag"],
|
|
66
|
+
"savedAt" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
|
|
67
|
+
"payload" => ruleset
|
|
68
|
+
)
|
|
69
|
+
return if body.bytesize > MAX_CACHE_BYTES
|
|
70
|
+
|
|
71
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
72
|
+
tmp = Tempfile.create(".evolve-flags-", dir)
|
|
73
|
+
begin
|
|
74
|
+
tmp.write(body)
|
|
75
|
+
tmp.close
|
|
76
|
+
File.chmod(0o600, tmp.path)
|
|
77
|
+
File.rename(tmp.path, File.join(dir, cache_name(key)))
|
|
78
|
+
ensure
|
|
79
|
+
tmp.close unless tmp.closed?
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
rescue SystemCallError, IOError, TypeError, JSON::GeneratorError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "evolve_logs/version"
|
|
4
|
+
|
|
5
|
+
require "evolve_logs/flags/kernel"
|
|
6
|
+
require "evolve_logs/flags/options"
|
|
7
|
+
require "evolve_logs/flags/cache"
|
|
8
|
+
require "evolve_logs/flags/exposures"
|
|
9
|
+
require "evolve_logs/flags/delivery"
|
|
10
|
+
|
|
11
|
+
module EvolveLogs
|
|
12
|
+
module Flags
|
|
13
|
+
# The public flags handle bound to one key (docs/LAUNCH-SDK.md §3):
|
|
14
|
+
# synchronous evaluation over the held ruleset. Evaluation never blocks
|
|
15
|
+
# and never raises; the delivery thread owns the network; ready() is the
|
|
16
|
+
# only call that waits, and only because the caller chose to.
|
|
17
|
+
class Client
|
|
18
|
+
def initialize(key, options, observer_url: nil, post_json: nil)
|
|
19
|
+
@key = key
|
|
20
|
+
@options = options
|
|
21
|
+
@post_json = post_json
|
|
22
|
+
@base_url = Flags.base_url(observer_url, options.url)
|
|
23
|
+
@log = LogOnce.new
|
|
24
|
+
@ruleset = nil
|
|
25
|
+
@fetched_environment_id = nil
|
|
26
|
+
@received_from_network = false
|
|
27
|
+
@updated_at = nil
|
|
28
|
+
@ready_mutex = Mutex.new
|
|
29
|
+
@ready_cv = ConditionVariable.new
|
|
30
|
+
@listeners = []
|
|
31
|
+
@listeners_mutex = Mutex.new
|
|
32
|
+
@cache_dir = options.cache == false ? nil : (options.cache || Cache.default_dir)
|
|
33
|
+
exposures = options.exposures
|
|
34
|
+
@exposures = ExposureRecorder.new(
|
|
35
|
+
enabled: exposures[:enabled] != false,
|
|
36
|
+
sample_rate: clamp01(exposures[:sample_rate] || 1.0),
|
|
37
|
+
dedupe_window_seconds: exposures[:dedupe_window_seconds] || 60,
|
|
38
|
+
send_attributes: exposures[:send_attributes] == true,
|
|
39
|
+
private_attributes: options.private_attributes
|
|
40
|
+
)
|
|
41
|
+
@delivery = ServerDelivery.new(
|
|
42
|
+
base_url: @base_url,
|
|
43
|
+
key: key,
|
|
44
|
+
user_agent: "e-volv-logs-ruby/#{EvolveLogs::VERSION}",
|
|
45
|
+
mode: options.mode,
|
|
46
|
+
poll_interval_seconds: options.poll_interval_seconds,
|
|
47
|
+
stale_after_seconds: options.stale_after_seconds,
|
|
48
|
+
on_ruleset: ->(ruleset) { accept(ruleset, from_network: true) },
|
|
49
|
+
log: @log
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def start
|
|
54
|
+
accept(@options.bootstrap, from_network: false) if @options.bootstrap.is_a?(Hash)
|
|
55
|
+
if @cache_dir
|
|
56
|
+
cached = Cache.read(@cache_dir, @key)
|
|
57
|
+
accept(cached["payload"], from_network: false) if cached && @ruleset.nil?
|
|
58
|
+
end
|
|
59
|
+
@delivery.start(@ruleset ? @ruleset["etag"] : nil)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# -- reads -------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
# last_updated_at is the epoch seconds of the last confirmation, or of
|
|
66
|
+
# the last bootstrap/cache accept when the delivery thread has not
|
|
67
|
+
# confirmed yet.
|
|
68
|
+
def last_updated_at
|
|
69
|
+
@delivery.confirmed_at || @updated_at
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mode
|
|
73
|
+
@delivery.mode
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bool(key, default, context = nil)
|
|
77
|
+
run("boolean", key, default, context).value
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def string(key, default, context = nil)
|
|
81
|
+
run("string", key, default, context).value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def number(key, default, context = nil)
|
|
85
|
+
run("number", key, default, context).value
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def json(key, default, context = nil)
|
|
89
|
+
run(nil, key, default, context).value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def detail(key, default, context = nil)
|
|
93
|
+
run(nil, key, default, context)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# ready waits up to `timeout` seconds for the first ruleset from the
|
|
97
|
+
# network. Offline or bootstrap-fed clients answer immediately.
|
|
98
|
+
def ready(timeout: 5)
|
|
99
|
+
return !@ruleset.nil? if @received_from_network || @delivery.mode == "offline"
|
|
100
|
+
|
|
101
|
+
deadline = monotonic + timeout
|
|
102
|
+
@ready_mutex.synchronize do
|
|
103
|
+
until @received_from_network
|
|
104
|
+
remaining = deadline - monotonic
|
|
105
|
+
return false if remaining <= 0
|
|
106
|
+
|
|
107
|
+
@ready_cv.wait(@ready_mutex, remaining)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# on_change subscribes a listener for ruleset change notifications; the
|
|
114
|
+
# returned proc unsubscribes. Listener errors never propagate.
|
|
115
|
+
def on_change(&listener)
|
|
116
|
+
@listeners_mutex.synchronize { @listeners << listener }
|
|
117
|
+
-> { @listeners_mutex.synchronize { @listeners.delete(listener) } }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# verify pings the control plane; nil on any failure.
|
|
121
|
+
def verify
|
|
122
|
+
@delivery.ping
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# -- writes ------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def flush_exposures
|
|
128
|
+
loop do
|
|
129
|
+
batch = @exposures.drain(1000)
|
|
130
|
+
return if batch.empty?
|
|
131
|
+
|
|
132
|
+
status = 0
|
|
133
|
+
begin
|
|
134
|
+
status = @post_json ? @post_json.call("#{@base_url}/exposures", { "exposures" => batch }).to_i : 0
|
|
135
|
+
rescue StandardError
|
|
136
|
+
status = 0
|
|
137
|
+
end
|
|
138
|
+
# Sent, or an unrecoverable 4xx: drop and keep draining. 429 and
|
|
139
|
+
# transport errors requeue the batch and stop — the next flush
|
|
140
|
+
# retries.
|
|
141
|
+
next if (200..299).cover?(status) || (status != 429 && (400..499).cover?(status))
|
|
142
|
+
|
|
143
|
+
@exposures.requeue(batch)
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
rescue StandardError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def close
|
|
151
|
+
@delivery.close
|
|
152
|
+
# An orderly shutdown persists what it can: the delivery threads are
|
|
153
|
+
# stopped (a cache write in flight finishes first), then exposures go.
|
|
154
|
+
flush_exposures
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# -- internals ---------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def run(kind, key, default, context)
|
|
161
|
+
@delivery.ensure_running
|
|
162
|
+
ruleset = @ruleset
|
|
163
|
+
if ruleset.nil?
|
|
164
|
+
@log.once("not-ready", "e-volv flags: flags not ready — serving defaults until the first ruleset arrives")
|
|
165
|
+
return Evaluation.new(default, nil, "FLAG_NOT_FOUND")
|
|
166
|
+
end
|
|
167
|
+
ctx = stringify_keys(context.nil? ? {} : context)
|
|
168
|
+
result = Kernel.evaluate(ruleset, key, default, ctx)
|
|
169
|
+
return result if %w[FLAG_NOT_FOUND ERROR].include?(result.reason)
|
|
170
|
+
return Evaluation.new(default, nil, "TYPE_MISMATCH") if kind && JsValues.js_type(result.value) != kind
|
|
171
|
+
|
|
172
|
+
@exposures.record(key, result.variant, result.reason, ctx)
|
|
173
|
+
result
|
|
174
|
+
rescue StandardError, SystemStackError
|
|
175
|
+
Evaluation.new(default, nil, "ERROR")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# accept swaps the held ruleset; from the network it also persists the
|
|
179
|
+
# cache, marks readiness and notifies listeners.
|
|
180
|
+
def accept(next_ruleset, from_network:)
|
|
181
|
+
return unless next_ruleset.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
if from_network && @fetched_environment_id &&
|
|
184
|
+
next_ruleset["environmentId"] != @fetched_environment_id
|
|
185
|
+
@log.once("environment", "e-volv flags: this key now serves a different environment")
|
|
186
|
+
end
|
|
187
|
+
previous = @ruleset
|
|
188
|
+
@ruleset = next_ruleset
|
|
189
|
+
@updated_at = Time.now.to_f
|
|
190
|
+
if from_network
|
|
191
|
+
@fetched_environment_id = next_ruleset["environmentId"]
|
|
192
|
+
Cache.write(@cache_dir, @key, next_ruleset) if @cache_dir
|
|
193
|
+
@received_from_network = true
|
|
194
|
+
@ready_mutex.synchronize { @ready_cv.broadcast }
|
|
195
|
+
end
|
|
196
|
+
changed = changed_keys(previous, next_ruleset)
|
|
197
|
+
return if changed.empty?
|
|
198
|
+
|
|
199
|
+
listeners = @listeners_mutex.synchronize { @listeners.dup }
|
|
200
|
+
listeners.each do |listener|
|
|
201
|
+
begin
|
|
202
|
+
listener.call(changed)
|
|
203
|
+
rescue StandardError
|
|
204
|
+
nil # listener errors are the caller's
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
rescue StandardError
|
|
208
|
+
nil # a bad payload never breaks availability
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def changed_keys(previous, next_ruleset)
|
|
212
|
+
return [] unless previous.is_a?(Hash)
|
|
213
|
+
|
|
214
|
+
prev_flags = previous["flags"].is_a?(Hash) ? previous["flags"] : {}
|
|
215
|
+
next_flags = next_ruleset["flags"].is_a?(Hash) ? next_ruleset["flags"] : {}
|
|
216
|
+
(prev_flags.keys | next_flags.keys).select do |k|
|
|
217
|
+
JSON.generate(canonical(prev_flags[k])) != JSON.generate(canonical(next_flags[k]))
|
|
218
|
+
end.sort
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# canonical renders a value with sorted hash keys so two structurally
|
|
222
|
+
# equal rulesets compare equal (notification dedupe).
|
|
223
|
+
def canonical(value)
|
|
224
|
+
case value
|
|
225
|
+
when Hash
|
|
226
|
+
out = {}
|
|
227
|
+
value.keys.map(&:to_s).sort.each do |k|
|
|
228
|
+
original = value.key?(k) ? k : value.keys.find { |x| x.to_s == k }
|
|
229
|
+
out[k] = canonical(value[original])
|
|
230
|
+
end
|
|
231
|
+
out
|
|
232
|
+
when Array
|
|
233
|
+
value.map { |v| canonical(v) }
|
|
234
|
+
else
|
|
235
|
+
value
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# stringify_keys deep-converts caller contexts (which may use symbols)
|
|
240
|
+
# to the string keys the kernel and the wire speak.
|
|
241
|
+
def stringify_keys(value)
|
|
242
|
+
case value
|
|
243
|
+
when Hash
|
|
244
|
+
out = {}
|
|
245
|
+
value.each { |k, v| out[k.to_s] = stringify_keys(v) }
|
|
246
|
+
out
|
|
247
|
+
when Array
|
|
248
|
+
value.map { |v| stringify_keys(v) }
|
|
249
|
+
else
|
|
250
|
+
value
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def clamp01(rate)
|
|
255
|
+
rate = rate.to_f
|
|
256
|
+
return 1.0 if rate.nan?
|
|
257
|
+
|
|
258
|
+
[[rate, 0.0].max, 1.0].min
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def monotonic
|
|
262
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The flags handle when flags are disabled (no key, or enabled: false):
|
|
267
|
+
# defaults, FLAG_NOT_FOUND, no timers, no threads.
|
|
268
|
+
class Disabled
|
|
269
|
+
def last_updated_at
|
|
270
|
+
nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def mode
|
|
274
|
+
"offline"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def bool(_key, default, _context = nil)
|
|
278
|
+
default
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def string(_key, default, _context = nil)
|
|
282
|
+
default
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def number(_key, default, _context = nil)
|
|
286
|
+
default
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def json(_key, default, _context = nil)
|
|
290
|
+
default
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def detail(_key, default, _context = nil)
|
|
294
|
+
Evaluation.new(default, nil, "FLAG_NOT_FOUND")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def ready(timeout: 5)
|
|
298
|
+
false
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def on_change(&_listener)
|
|
302
|
+
-> {}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def verify
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def flush_exposures; end
|
|
310
|
+
|
|
311
|
+
def close; end
|
|
312
|
+
|
|
313
|
+
def start; end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EvolveLogs
|
|
4
|
+
module Flags
|
|
5
|
+
# Port of packages/flags-kernel/src/context.ts: reading a context,
|
|
6
|
+
# whatever shape it arrived in.
|
|
7
|
+
module Context
|
|
8
|
+
# The kind a context without one is taken to be.
|
|
9
|
+
DEFAULT_CONTEXT_KIND = "user"
|
|
10
|
+
# A canonical non-negative array index: "0", not "01".
|
|
11
|
+
INDEX = /\A(?:0|[1-9]\d*)\z/.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# resolve splits a context into its kinds: a multi-context yields one
|
|
16
|
+
# entry per nested kind; anything else yields a single entry under its
|
|
17
|
+
# own kind, defaulting to "user".
|
|
18
|
+
def resolve(ctx)
|
|
19
|
+
resolved = {}
|
|
20
|
+
return resolved unless ctx.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
if ctx["kind"] == "multi"
|
|
23
|
+
ctx.each do |kind, nested|
|
|
24
|
+
next if kind == "kind" || !nested.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
resolved[kind] = nested.merge("kind" => kind)
|
|
27
|
+
end
|
|
28
|
+
return resolved
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
kind = ctx["kind"]
|
|
32
|
+
kind = DEFAULT_CONTEXT_KIND unless kind.is_a?(String) && !kind.empty?
|
|
33
|
+
resolved[kind] = ctx
|
|
34
|
+
resolved
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# context_key: `key` if present and not null, else `targetingKey`; the
|
|
38
|
+
# chosen value must be a non-empty string.
|
|
39
|
+
def context_key(c)
|
|
40
|
+
return nil unless c.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
key = c["key"]
|
|
43
|
+
key = c["targetingKey"] if key.nil?
|
|
44
|
+
key.is_a?(String) && !key.empty? ? key : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# key_of_kind: the default kind applies only when kind is absent — an
|
|
48
|
+
# empty string is looked up as "" (PORTING.md).
|
|
49
|
+
def key_of_kind(contexts, kind = JsValues::MISSING)
|
|
50
|
+
return nil unless contexts.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
context_key(contexts[kind_or_default(kind)])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# kind_or_default: the kindOrDefault port. Absent and empty mean
|
|
56
|
+
# "user" — that is what every ruleset written before kinds meant.
|
|
57
|
+
# null is present, not absent: it names a kind no context carries, so
|
|
58
|
+
# the lookup misses. Collapsing null into "user" would make a rule the
|
|
59
|
+
# author misconfigured silently target everybody.
|
|
60
|
+
def kind_or_default(kind)
|
|
61
|
+
kind.equal?(JsValues::MISSING) || kind == "" ? DEFAULT_CONTEXT_KIND : kind
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# attribute_path splits an attribute reference: `/address/city` is a
|
|
65
|
+
# JSON-pointer-style path (~1 → /, ~0 → ~), `address.city` the earlier
|
|
66
|
+
# e-volv spelling, anything else one literal key.
|
|
67
|
+
def attribute_path(a)
|
|
68
|
+
if a.start_with?("/")
|
|
69
|
+
return a[1..-1].split("/")
|
|
70
|
+
.map { |part| part.gsub("~1", "/").gsub("~0", "~") }
|
|
71
|
+
.reject { |part| part.empty? }
|
|
72
|
+
end
|
|
73
|
+
return a.split(".") if a.include?(".")
|
|
74
|
+
|
|
75
|
+
[a]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# attribute_of reads one attribute off one context. `key`, `kind`,
|
|
79
|
+
# `name` and `anonymous` are built in and always answer; the absent
|
|
80
|
+
# sentinel means "no value".
|
|
81
|
+
def attribute_of(c, a)
|
|
82
|
+
return JsValues::MISSING unless c.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
# The one alias: every ruleset and every OpenFeature caller written
|
|
85
|
+
# before contexts says `targetingKey` where it means the key.
|
|
86
|
+
if a == "targetingKey" || a == "key"
|
|
87
|
+
key = context_key(c)
|
|
88
|
+
return key.nil? ? JsValues::MISSING : key
|
|
89
|
+
end
|
|
90
|
+
if a == "kind"
|
|
91
|
+
kind = c["kind"]
|
|
92
|
+
return kind.nil? ? DEFAULT_CONTEXT_KIND : kind
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
cursor = c
|
|
96
|
+
attribute_path(a).each do |segment|
|
|
97
|
+
return JsValues::MISSING unless cursor.is_a?(Hash) || cursor.is_a?(Array)
|
|
98
|
+
|
|
99
|
+
cursor = step(cursor, segment)
|
|
100
|
+
end
|
|
101
|
+
cursor
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# attribute_of_kind reads an attribute from the named kind; absent or
|
|
105
|
+
# empty kind means "user", null names a kind no context carries and
|
|
106
|
+
# misses (PORTING.md).
|
|
107
|
+
def attribute_of_kind(contexts, kind, a)
|
|
108
|
+
return JsValues::MISSING unless contexts.is_a?(Hash)
|
|
109
|
+
|
|
110
|
+
attribute_of(contexts[kind_or_default(kind)], a)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# step: a hash answers a key lookup; an array answers a canonical index
|
|
114
|
+
# or `length`; anything else is absent.
|
|
115
|
+
def step(cursor, segment)
|
|
116
|
+
if cursor.is_a?(Hash)
|
|
117
|
+
return cursor.key?(segment) ? cursor[segment] : JsValues::MISSING
|
|
118
|
+
end
|
|
119
|
+
if cursor.is_a?(Array)
|
|
120
|
+
return cursor.length if segment == "length"
|
|
121
|
+
return cursor[segment.to_i] if INDEX.match?(segment) && segment.to_i < cursor.length
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
JsValues::MISSING
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|