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,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require "evolve_logs/flags/kernel"
8
+ require "evolve_logs/flags/sse"
9
+
10
+ module EvolveLogs
11
+ module Flags
12
+ # A flag condition is logged once, not once per evaluation: the first
13
+ # occurrence writes one line to stderr, later ones are silent until the
14
+ # condition resets (a confirmation resets outage/stale/auth).
15
+ class LogOnce
16
+ def initialize
17
+ @seen = {}
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def once(log_id, message)
22
+ @mutex.synchronize do
23
+ return if @seen[log_id]
24
+
25
+ @seen[log_id] = true
26
+ end
27
+ $stderr.puts(message)
28
+ nil
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ def reset(log_id)
34
+ @mutex.synchronize { @seen.delete(log_id) }
35
+ nil
36
+ rescue StandardError
37
+ nil
38
+ end
39
+ end
40
+
41
+ # Server ruleset delivery: bootstrap + SSE stream with polling fallback.
42
+ # One daemon thread owns the state machine; evaluation threads never
43
+ # touch it except to read confirmed_at (docs/LAUNCH-SDK.md §4, §5;
44
+ # PORTING.md "Delivery behaviour"). Fork safety lives in ensure_running.
45
+ class ServerDelivery
46
+ STREAM_IDLE_S = 60.0 # no bytes for this long → abort the stream
47
+ STREAM_RETRY_WHILE_POLLING_S = 300.0
48
+ AUTH_RETRY_S = 300.0
49
+
50
+ attr_reader :mode
51
+ attr_accessor :confirmed_at
52
+
53
+ def initialize(base_url:, key:, user_agent:, mode:, poll_interval_seconds:, stale_after_seconds:,
54
+ on_ruleset:, log:, rng: nil)
55
+ @base_url = base_url
56
+ @key = key
57
+ @user_agent = user_agent
58
+ @mode = mode
59
+ @poll_interval = [15.0, poll_interval_seconds.to_f].max
60
+ @stale_after = stale_after_seconds.to_f
61
+ @on_ruleset = on_ruleset
62
+ @log = log
63
+ @rng = rng || Random
64
+ @confirmed_at = nil
65
+ @disabled = false
66
+ @etag = nil
67
+ @stop = false
68
+ @closed = false
69
+ @thread = nil
70
+ @stale_thread = nil
71
+ @pid = nil
72
+ @launch_mutex = Mutex.new
73
+ @wake_mutex = Mutex.new
74
+ @wake = ConditionVariable.new
75
+ @next_poll = 0.0
76
+ @next_stream = 0.0
77
+ @stream_failures = 0
78
+ @stream_http = nil
79
+ end
80
+
81
+ def disabled?
82
+ @disabled
83
+ end
84
+
85
+ # -- lifecycle -------------------------------------------------------
86
+
87
+ def start(held_etag)
88
+ @etag = held_etag
89
+ return if @mode == "offline"
90
+
91
+ launch
92
+ nil
93
+ end
94
+
95
+ def close
96
+ @closed = true
97
+ @stop = true
98
+ finish_stream
99
+ @wake_mutex.synchronize { @wake.broadcast }
100
+ join_thread(@thread)
101
+ join_thread(@stale_thread)
102
+ nil
103
+ end
104
+
105
+ # ensure_running relaunches the threads after fork — Puma, Unicorn,
106
+ # Resque and Sidekiq Enterprise fork after boot, and Ruby threads do
107
+ # not survive fork. Called from Flags#detail on the evaluation path;
108
+ # in the common case it is one integer comparison, no I/O.
109
+ def ensure_running
110
+ return if @closed || @disabled || @mode == "offline"
111
+ return if @pid == Process.pid && alive?(@thread)
112
+
113
+ @launch_mutex.synchronize do
114
+ next if @closed || @disabled
115
+ next if @pid == Process.pid && alive?(@thread)
116
+
117
+ launch
118
+ end
119
+ nil
120
+ end
121
+
122
+ def ping
123
+ response = http_get("/ping")
124
+ return nil unless response.code.to_i == 200
125
+
126
+ JSON.parse(response.body.to_s)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ # -- state machine -----------------------------------------------------
132
+
133
+ def launch
134
+ @pid = Process.pid
135
+ @stop = false
136
+ @thread = Thread.new { run }
137
+ @thread.name = "e-volv-flags"
138
+ @stale_thread = Thread.new { watch_stale }
139
+ @stale_thread.name = "e-volv-flags-stale"
140
+ end
141
+
142
+ def run
143
+ bootstrap
144
+ @next_poll = monotonic + @poll_interval if @next_poll <= monotonic
145
+ until @stop || @disabled
146
+ now = monotonic
147
+ if @mode == "stream" || (@next_stream.positive? && now >= @next_stream)
148
+ @next_stream = 0.0
149
+ begin
150
+ stream_once
151
+ rescue StandardError
152
+ return if @stop || @disabled
153
+
154
+ if @mode == "poll"
155
+ @next_stream = monotonic + STREAM_RETRY_WHILE_POLLING_S
156
+ else
157
+ @stream_failures += 1
158
+ if @stream_failures >= 2
159
+ fall_back_to_polling(0.0)
160
+ else
161
+ wait(Flags.full_jitter_seconds(@stream_failures, @rng))
162
+ end
163
+ end
164
+ end
165
+ next
166
+ end
167
+ if now >= @next_poll
168
+ bootstrap
169
+ # A 429 Retry-After or 401/404 five-minute deadline the bootstrap
170
+ # just scheduled stays untouched.
171
+ @next_poll = monotonic + @poll_interval if @next_poll <= now
172
+ end
173
+ wait(0.25)
174
+ end
175
+ nil
176
+ rescue StandardError
177
+ nil
178
+ end
179
+
180
+ def watch_stale
181
+ until @stop
182
+ wait(1.0)
183
+ break if @stop
184
+
185
+ next if @confirmed_at.nil?
186
+
187
+ age = Time.now.to_f - @confirmed_at
188
+ next unless age > @stale_after
189
+
190
+ @log.once("stale", "e-volv flags: ruleset is stale (no confirmation for #{age.to_i}s)")
191
+ end
192
+ nil
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
197
+ def bootstrap
198
+ return if @stop || @disabled
199
+
200
+ headers = @etag ? { "if-none-match" => %("#{@etag}") } : {}
201
+ response = http_get("/bootstrap?v=#{Kernel::RULESET_VERSION}", headers)
202
+ status = response.code.to_i
203
+ if status == 304
204
+ confirmed
205
+ return
206
+ end
207
+ if status == 200
208
+ body = parse_json(response.body)
209
+ return unless body.is_a?(Hash)
210
+
211
+ version = body["rulesetVersion"]
212
+ if !version.is_a?(Numeric) || version > Kernel::RULESET_VERSION
213
+ shown = version.is_a?(Numeric) ? version : "unknown"
214
+ @log.once("version", "e-volv flags: ignoring a ruleset newer than this SDK reads (v#{shown}); upgrade e-volv-logs")
215
+ return
216
+ end
217
+ @etag = body["etag"]
218
+ confirmed
219
+ @on_ruleset.call(body)
220
+ return
221
+ end
222
+ on_status(status, response.body.to_s, response)
223
+ rescue StandardError
224
+ @log.once("outage", "e-volv flags: control plane unreachable, serving last known values")
225
+ nil
226
+ end
227
+
228
+ # on_status handles a non-2xx bootstrap/stream-connect response per the
229
+ # delivery table.
230
+ def on_status(status, text, response)
231
+ if status == 403 && text.include?("lacks the scope")
232
+ @disabled = true
233
+ @log.once("scope", "e-volv flags: this key lacks flags:read — flags are off, telemetry is unaffected")
234
+ close
235
+ return
236
+ end
237
+ if [401, 403, 404].include?(status)
238
+ @log.once("auth", "e-volv flags: the control plane refused this key (#{status}); serving last known values and retrying every 5 minutes")
239
+ @next_poll = monotonic + AUTH_RETRY_S
240
+ return
241
+ end
242
+ if status == 429
243
+ retry_after = parse_retry_after(response["retry-after"]) || 30.0
244
+ @next_poll = monotonic + [retry_after, 1.0].max
245
+ return
246
+ end
247
+ @log.once("outage", "e-volv flags: control plane returned #{status}, serving last known values")
248
+ end
249
+
250
+ # stream_once blocks while the stream is healthy; it returns on a 429
251
+ # (poll fallback) and raises on any other failure — the run loop counts
252
+ # consecutive failures and backs off or falls back.
253
+ def stream_once
254
+ ended_cleanly = false
255
+ http = build_http(STREAM_IDLE_S)
256
+ request = Net::HTTP::Get.new(stream_path)
257
+ headers("accept" => "text/event-stream").each { |k, v| request[k] = v }
258
+ @stream_http = http
259
+ http.request(request) do |response|
260
+ status = response.code.to_i
261
+ if status == 429
262
+ fall_back_to_polling(parse_retry_after(response["retry-after"]) || 0.0)
263
+ ended_cleanly = true
264
+ next
265
+ end
266
+ unless status == 200
267
+ on_status(status, response.body.to_s, response) if [401, 403, 404].include?(status)
268
+ raise "stream #{status}"
269
+ end
270
+ @stream_failures = 0
271
+ @mode = "stream"
272
+
273
+ parser = SseParser.new { |event, data| on_stream_event(event, data) }
274
+ response.read_body do |chunk|
275
+ return if @stop
276
+
277
+ parser.push(chunk.to_s.force_encoding(Encoding::UTF_8))
278
+ end
279
+ end
280
+ raise "stream ended" unless ended_cleanly || @stop
281
+ ensure
282
+ @stream_http = nil
283
+ end
284
+
285
+ def on_stream_event(event, data)
286
+ confirmed
287
+ return unless event == "ruleset"
288
+
289
+ message = parse_json(data)
290
+ return unless message.is_a?(Hash)
291
+
292
+ bootstrap if message.key?("changedAt") || message["etag"] != @etag
293
+ end
294
+
295
+ def fall_back_to_polling(retry_stream_after)
296
+ @mode = "poll"
297
+ @next_poll = 0.0
298
+ @next_stream = monotonic + [STREAM_RETRY_WHILE_POLLING_S, retry_stream_after].max
299
+ end
300
+
301
+ def confirmed
302
+ @confirmed_at = Time.now.to_f
303
+ @log.reset("outage")
304
+ @log.reset("stale")
305
+ @log.reset("auth")
306
+ end
307
+
308
+ # -- HTTP plumbing -----------------------------------------------------
309
+
310
+ def build_http(read_timeout)
311
+ uri = URI.parse(@base_url)
312
+ http = Net::HTTP.new(uri.host, uri.port)
313
+ http.use_ssl = uri.scheme == "https"
314
+ http.open_timeout = 10
315
+ http.read_timeout = read_timeout
316
+ http
317
+ end
318
+
319
+ def stream_path
320
+ uri = URI.parse(@base_url)
321
+ path = uri.request_uri
322
+ path = "/" if path.nil? || path.empty?
323
+ "#{path}/stream"
324
+ end
325
+
326
+ def http_get(path, extra = {})
327
+ uri = URI.parse("#{@base_url}#{path}")
328
+ http = Net::HTTP.new(uri.host, uri.port)
329
+ http.use_ssl = uri.scheme == "https"
330
+ http.open_timeout = 10
331
+ http.read_timeout = 10
332
+ request = Net::HTTP::Get.new(uri.request_uri.empty? ? "/" : uri.request_uri)
333
+ headers(extra).each { |k, v| request[k] = v }
334
+ http.start { |conn| conn.request(request) }
335
+ end
336
+
337
+ def headers(extra = {})
338
+ base = { "authorization" => "Bearer #{@key}", "user-agent" => @user_agent }
339
+ extra.each { |k, v| base[k] = v }
340
+ base
341
+ end
342
+
343
+ # -- small helpers -------------------------------------------------------
344
+
345
+ def wait(seconds)
346
+ @wake_mutex.synchronize do
347
+ @wake.wait(@wake_mutex, seconds) unless @stop
348
+ end
349
+ nil
350
+ end
351
+
352
+ def join_thread(thread)
353
+ thread&.join(1) unless thread.nil? || thread == Thread.current
354
+ nil
355
+ end
356
+
357
+ def finish_stream
358
+ http = @stream_http
359
+ http&.finish
360
+ nil
361
+ rescue StandardError
362
+ nil
363
+ end
364
+
365
+ def alive?(thread)
366
+ !thread.nil? && thread.alive?
367
+ end
368
+
369
+ def parse_json(text)
370
+ JSON.parse(text.to_s)
371
+ rescue JSON::ParserError, TypeError
372
+ nil
373
+ end
374
+
375
+ def parse_retry_after(value)
376
+ return nil if value.nil?
377
+
378
+ text = value.to_s.strip
379
+ return nil if text.empty?
380
+
381
+ Float(text)
382
+ rescue ArgumentError, TypeError
383
+ nil
384
+ end
385
+
386
+ def monotonic
387
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module EvolveLogs
6
+ module Flags
7
+ # Exposure recording: sampling first, then de-dupe by flag/variant/kind/
8
+ # subject inside a window — suppressed repeats ride as `count` on the
9
+ # first exposure after the window (docs/LAUNCH-SDK.md §7). Rows carry
10
+ # camelCase wire keys. Everything is best-effort: never raises.
11
+ class ExposureRecorder
12
+ BUFFER_CAP = 10_000
13
+ SEEN_CAP = 50_000
14
+ RESERVED = %w[kind key targetingKey name anonymous].freeze
15
+
16
+ def initialize(enabled: true, sample_rate: 1.0, dedupe_window_seconds: 60,
17
+ send_attributes: false, private_attributes: [],
18
+ now: nil, rnd: nil, uuid: nil)
19
+ @enabled = enabled
20
+ @sample_rate = sample_rate
21
+ @window = dedupe_window_seconds
22
+ @send_attributes = send_attributes
23
+ @private = (private_attributes || []).map(&:to_s)
24
+ @now = now || -> { Time.now.to_f }
25
+ @rnd = rnd || -> { Random.rand }
26
+ @uuid = uuid || -> { SecureRandom.uuid }
27
+ @queue = []
28
+ @seen = {}
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def record(flag_key, variant, reason, context)
33
+ @mutex.synchronize do
34
+ return unless @enabled
35
+
36
+ rate = @sample_rate
37
+ return if rate < 1 && @rnd.call >= rate
38
+
39
+ subject = subject_of(context)
40
+ dedupe_key = "#{flag_key}\0#{variant || ''}\0#{subject['kind']}\0#{subject['key'] || ''}"
41
+ at = @now.call
42
+ entry = @seen[dedupe_key]
43
+ if entry && at - entry[:at] < @window
44
+ entry[:suppressed] += 1
45
+ return
46
+ end
47
+ count = 1 + (entry ? entry[:suppressed] : 0)
48
+ @seen[dedupe_key] = { at: at, suppressed: 0 }
49
+ prune_seen(at) if @seen.size > SEEN_CAP
50
+
51
+ row = {
52
+ "id" => @uuid.call.to_s,
53
+ "ts" => Time.at(at).utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"),
54
+ "flagKey" => flag_key.to_s[0, 64],
55
+ "reason" => reason.to_s[0, 40],
56
+ "contextKind" => subject["kind"].to_s[0, 64],
57
+ }
58
+ row["variant"] = variant.to_s[0, 60] if variant
59
+ row["subject"] = subject["key"] if subject["key"]
60
+ row["contextName"] = subject["name"] if subject["name"]
61
+ row["anonymous"] = true if subject["anonymous"]
62
+ row["count"] = [count, 1_000_000].min if count > 1
63
+ row["sampleRate"] = rate if rate < 1
64
+ if @send_attributes
65
+ row["attributes"] = subject["attributes"].reject { |k, _| @private.include?(k) }
66
+ end
67
+ @queue << row
68
+ @queue.shift(@queue.length - BUFFER_CAP) if @queue.length > BUFFER_CAP
69
+ end
70
+ nil
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ def pending
76
+ @mutex.synchronize { @queue.length }
77
+ end
78
+
79
+ def drain(limit = 1000)
80
+ @mutex.synchronize { @queue.shift([limit, @queue.length].min) }
81
+ end
82
+
83
+ # requeue puts a batch that could not be sent back at the front, still
84
+ # capped — the oldest overflow is dropped.
85
+ def requeue(batch)
86
+ @mutex.synchronize { @queue = (batch + @queue).last(BUFFER_CAP) }
87
+ nil
88
+ end
89
+
90
+ # subject_of extracts the subject fields of one evaluation context in
91
+ # camelCase wire shape. A multi-context contributes its first kind.
92
+ def subject_of(context)
93
+ raw = context.is_a?(Hash) ? context : {}
94
+ kind = raw["kind"]
95
+ kind = "user" unless kind.is_a?(String)
96
+ single = raw
97
+ if kind == "multi"
98
+ single = {}
99
+ raw.each do |k, v|
100
+ next if k == "kind" || !v.is_a?(Hash)
101
+
102
+ kind = k
103
+ single = v
104
+ break
105
+ end
106
+ end
107
+ key = single["key"]
108
+ key = single["targetingKey"] if key.nil?
109
+ name = single["name"]
110
+ attributes = {}
111
+ single.each { |k, v| attributes[k] = v unless RESERVED.include?(k) }
112
+ {
113
+ "kind" => kind,
114
+ "key" => key.nil? ? nil : key.to_s[0, 200],
115
+ "name" => name.is_a?(String) ? name[0, 200] : nil,
116
+ "anonymous" => single["anonymous"].equal?(true) ? true : nil,
117
+ "attributes" => attributes,
118
+ }
119
+ end
120
+
121
+ private
122
+
123
+ def prune_seen(at)
124
+ @seen.delete_if { |_, v| at - v[:at] >= @window && v[:suppressed].zero? }
125
+ end
126
+ end
127
+ end
128
+ end