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,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
|