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.
@@ -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