flagnetic 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 +202 -0
- data/README.md +276 -0
- data/lib/flagnetic/backoff.rb +29 -0
- data/lib/flagnetic/changes.rb +88 -0
- data/lib/flagnetic/client.rb +609 -0
- data/lib/flagnetic/controller_helpers.rb +97 -0
- data/lib/flagnetic/detail.rb +73 -0
- data/lib/flagnetic/eval/evaluation.rb +382 -0
- data/lib/flagnetic/eval/instant.rb +71 -0
- data/lib/flagnetic/eval/murmur3.rb +70 -0
- data/lib/flagnetic/eval/operators.rb +225 -0
- data/lib/flagnetic/eval/regex.rb +647 -0
- data/lib/flagnetic/eval/ruleset.rb +267 -0
- data/lib/flagnetic/eval/semver.rb +80 -0
- data/lib/flagnetic/eval/types.rb +148 -0
- data/lib/flagnetic/eval/unicode_tables.rb +180 -0
- data/lib/flagnetic/eval.rb +38 -0
- data/lib/flagnetic/event_batch.rb +119 -0
- data/lib/flagnetic/exposures.rb +342 -0
- data/lib/flagnetic/fallback_chain.rb +230 -0
- data/lib/flagnetic/generations.rb +50 -0
- data/lib/flagnetic/global.rb +59 -0
- data/lib/flagnetic/local_overrides.rb +65 -0
- data/lib/flagnetic/logging.rb +62 -0
- data/lib/flagnetic/railtie.rb +146 -0
- data/lib/flagnetic/recorder.rb +135 -0
- data/lib/flagnetic/reporter.rb +94 -0
- data/lib/flagnetic/sse_parser.rb +94 -0
- data/lib/flagnetic/store.rb +34 -0
- data/lib/flagnetic/stream.rb +65 -0
- data/lib/flagnetic/summaries.rb +257 -0
- data/lib/flagnetic/testing.rb +160 -0
- data/lib/flagnetic/transport.rb +106 -0
- data/lib/flagnetic/uuid.rb +25 -0
- data/lib/flagnetic/version.rb +16 -0
- data/lib/flagnetic/worker.rb +270 -0
- data/lib/flagnetic.rb +34 -0
- metadata +79 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
# Stream is one request of the change stream, read on a thread of its own
|
|
7
|
+
# so that the worker can keep its timers while the stream is quiet. What
|
|
8
|
+
# happens is posted to the worker's inbox as [:stream, id, kind, ...]:
|
|
9
|
+
#
|
|
10
|
+
# - :head, status: the response head arrived.
|
|
11
|
+
# - :frame, kind, value: a parsed frame of a 200 response.
|
|
12
|
+
# - :ended, cause, error: the request is over; error is false for the
|
|
13
|
+
# server closing the stream, true for anything else, a connection that
|
|
14
|
+
# fails, a malformed stream, or silence for longer than the read
|
|
15
|
+
# timeout the stream is held with.
|
|
16
|
+
#
|
|
17
|
+
# The read timeout is the silence limit: Net::HTTP raises when no byte,
|
|
18
|
+
# a heartbeat included, arrives within it.
|
|
19
|
+
class Stream
|
|
20
|
+
def initialize(id, transport, held_version, silence:, inbox:)
|
|
21
|
+
@id = id
|
|
22
|
+
@transport = transport
|
|
23
|
+
@request = transport.stream_request(held_version)
|
|
24
|
+
@silence = silence
|
|
25
|
+
@inbox = inbox
|
|
26
|
+
@thread = Thread.new { run }
|
|
27
|
+
@thread.name = "flagnetic-stream"
|
|
28
|
+
@thread.report_on_exception = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :id
|
|
32
|
+
|
|
33
|
+
# close ends the request. Killing the thread runs the ensure of
|
|
34
|
+
# Net::HTTP#start, which closes the socket.
|
|
35
|
+
def close
|
|
36
|
+
@thread.kill
|
|
37
|
+
@thread.join(1)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
http = @transport.http_for(@transport.stream_uri, @transport.timeout)
|
|
44
|
+
http.start { |h| h.request(@request) { |response| read(h, response) } }
|
|
45
|
+
post(:ended, "closed by the server", false)
|
|
46
|
+
rescue Net::ReadTimeout
|
|
47
|
+
post(:ended, "no frame for #{@silence} s", true)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
post(:ended, "#{e.class}: #{e.message}", true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def read(http, response)
|
|
53
|
+
status = response.code.to_i
|
|
54
|
+
post(:head, status)
|
|
55
|
+
return unless status == 200
|
|
56
|
+
|
|
57
|
+
http.read_timeout = @silence
|
|
58
|
+
parser = SSEParser.new
|
|
59
|
+
response.read_body { |chunk| parser.feed(chunk) { |kind, value| post(:frame, kind, value) } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def post(*message) = @inbox << [:stream, @id, *message]
|
|
63
|
+
end
|
|
64
|
+
private_constant :Stream
|
|
65
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "event_batch"
|
|
5
|
+
require_relative "reporter"
|
|
6
|
+
require_relative "uuid"
|
|
7
|
+
|
|
8
|
+
module Flagnetic
|
|
9
|
+
# Summaries counts every evaluation and reports each interval's window as
|
|
10
|
+
# one or more evaluation summary events: counts per (flag, variation,
|
|
11
|
+
# reason), the ruleset version at the interval's end, no unit IDs. A
|
|
12
|
+
# window whose report failed merges into the next one rather than being
|
|
13
|
+
# retried under its own event ID; one older than an hour is dropped
|
|
14
|
+
# instead. Offline: the window rotates only on flush and close, and goes
|
|
15
|
+
# to the Recorder instead of the events endpoint.
|
|
16
|
+
class Summaries
|
|
17
|
+
include Reporter
|
|
18
|
+
|
|
19
|
+
MAX_SUMMARY_ROWS = 1000
|
|
20
|
+
# MAX_WINDOW_AGE is how old a window whose report failed may be before
|
|
21
|
+
# it is dropped rather than merged forward: an hour, so a
|
|
22
|
+
# long outage does not eventually deliver counts nobody can place.
|
|
23
|
+
MAX_WINDOW_AGE = 3600
|
|
24
|
+
|
|
25
|
+
# LogicalEvent is one evaluation summary before it is wire-encoded:
|
|
26
|
+
# counts as [flag, variation, reason, count] tuples, reason a symbol.
|
|
27
|
+
LogicalEvent = Struct.new(:event_id, :interval_start, :interval_end, :ruleset_version, :counts, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# app_version, when given, goes with every summary.
|
|
30
|
+
def initialize(timing:, log:, ruleset_version:, transport: nil, record: nil, app_version: nil)
|
|
31
|
+
@transport = transport
|
|
32
|
+
@app_version = app_version
|
|
33
|
+
@timing = timing
|
|
34
|
+
@log = log
|
|
35
|
+
@record = record
|
|
36
|
+
@ruleset_version = ruleset_version
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@wall_start = Time.now
|
|
39
|
+
@window_due_since = now
|
|
40
|
+
@current = Hash.new(0)
|
|
41
|
+
@kept = nil
|
|
42
|
+
@backoff = Backoff.new(base: timing.fetch_base, cap: timing.fetch_cap)
|
|
43
|
+
@not_before = 0
|
|
44
|
+
@revoked_logged = false
|
|
45
|
+
@send_mutex = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# offline? reports whether the window goes to the Recorder rather than
|
|
49
|
+
# to the events endpoint.
|
|
50
|
+
def offline? = !@record.nil?
|
|
51
|
+
|
|
52
|
+
# count records one served value: the flag, the variation and the
|
|
53
|
+
# evaluation reason, never the value and never the context.
|
|
54
|
+
def count(flag, detail)
|
|
55
|
+
key = [flag, detail.variation, detail.reason]
|
|
56
|
+
@mutex.synchronize { @current[key] += 1 }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# due? reports whether the reporting interval has elapsed and the
|
|
60
|
+
# backoff since the last failure, if any, has too.
|
|
61
|
+
def due?(mono = now)
|
|
62
|
+
return false if offline?
|
|
63
|
+
|
|
64
|
+
mono >= @not_before && mono - @window_due_since >= @timing.summary_flush
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# next_deadline is when due? might next turn true, for a worker's wait
|
|
68
|
+
# to schedule around; nil offline, where nothing drives it.
|
|
69
|
+
def next_deadline
|
|
70
|
+
return nil if offline?
|
|
71
|
+
|
|
72
|
+
[@not_before, @window_due_since + @timing.summary_flush].max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# flush_once closes the current window and delivers it (or, offline,
|
|
76
|
+
# hands it to the Recorder), retrying with backoff within budget
|
|
77
|
+
# seconds. Safe to call from any thread; concurrent callers wait for
|
|
78
|
+
# each other only up to budget, so one never blocks behind another
|
|
79
|
+
# past its own.
|
|
80
|
+
def flush_once(budget)
|
|
81
|
+
deadline = now + budget
|
|
82
|
+
return true unless wait_for_send_lock(deadline)
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
w = take_window
|
|
86
|
+
w ? send_window(w, deadline) : true
|
|
87
|
+
ensure
|
|
88
|
+
@send_mutex.unlock
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# reset_after_fork empties the window in a forked child, which starts
|
|
93
|
+
# empty; the parent, which never calls this, keeps its own.
|
|
94
|
+
def reset_after_fork
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
@current = Hash.new(0)
|
|
97
|
+
@kept = nil
|
|
98
|
+
@wall_start = Time.now
|
|
99
|
+
@window_due_since = now
|
|
100
|
+
end
|
|
101
|
+
@backoff.reset
|
|
102
|
+
@not_before = 0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def kind = "evaluation_summary"
|
|
108
|
+
|
|
109
|
+
# take_window closes the current window, merging in one a failed
|
|
110
|
+
# report kept unless it began over an hour ago, and returns nil when
|
|
111
|
+
# nothing was evaluated.
|
|
112
|
+
def take_window
|
|
113
|
+
wall_end = Time.now
|
|
114
|
+
win, kept = @mutex.synchronize do
|
|
115
|
+
window = { start: @wall_start, end: wall_end, counts: @current }
|
|
116
|
+
held = @kept
|
|
117
|
+
@wall_start = wall_end
|
|
118
|
+
@window_due_since = now
|
|
119
|
+
@current = Hash.new(0)
|
|
120
|
+
@kept = nil
|
|
121
|
+
[window, held]
|
|
122
|
+
end
|
|
123
|
+
merge_kept(win, kept, wall_end) if kept
|
|
124
|
+
win[:counts].empty? ? nil : win
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def merge_kept(win, kept, wall_end)
|
|
128
|
+
age = wall_end - kept[:start]
|
|
129
|
+
if age > MAX_WINDOW_AGE
|
|
130
|
+
log(:warn, :queue_dropped, kind: kind, rows: kept[:counts].size, cause: "window_older_than_one_hour",
|
|
131
|
+
age: age.round(3))
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
win[:start] = kept[:start] if kept[:start] < win[:start]
|
|
135
|
+
kept[:counts].each { |k, n| win[:counts][k] = (win[:counts][k] || 0) + n }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# keep puts a window back, to merge into the next one taken.
|
|
139
|
+
def keep(window)
|
|
140
|
+
@mutex.synchronize do
|
|
141
|
+
if @kept.nil?
|
|
142
|
+
@kept = window
|
|
143
|
+
next
|
|
144
|
+
end
|
|
145
|
+
@kept[:start] = window[:start] if window[:start] < @kept[:start]
|
|
146
|
+
window[:counts].each { |k, n| @kept[:counts][k] = (@kept[:counts][k] || 0) + n }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def send_window(win, deadline)
|
|
151
|
+
events = build_events(win)
|
|
152
|
+
if offline?
|
|
153
|
+
events.each { |e| @record.call(public_summary(e)) }
|
|
154
|
+
return true
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
wire_events = events.map { |e| wire(e) }
|
|
158
|
+
batches = EventBatch.pack(wire_events, @transport.identity)
|
|
159
|
+
batches.each_with_index do |group, i|
|
|
160
|
+
result = attempt(group, deadline)
|
|
161
|
+
case result.kind
|
|
162
|
+
when :accepted
|
|
163
|
+
@backoff.reset
|
|
164
|
+
@not_before = 0
|
|
165
|
+
@revoked_logged = false
|
|
166
|
+
report_accepted(group, result)
|
|
167
|
+
when :dropped
|
|
168
|
+
report_dropped(group, result)
|
|
169
|
+
else
|
|
170
|
+
keep(window_of(batches[i..].flatten(1)))
|
|
171
|
+
return false
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# build_events turns a window into logical summary events: the rows
|
|
178
|
+
# ordered, at most 1000 to an event, each with its own event ID and all
|
|
179
|
+
# naming the window's interval and the ruleset version held at its end.
|
|
180
|
+
def build_events(win)
|
|
181
|
+
rows = win[:counts].map { |(flag, variation, reason), n| { flag:, variation:, reason:, count: n } }
|
|
182
|
+
rows.sort_by! { |r| [r[:flag], r[:variation].to_s, r[:reason].to_s] }
|
|
183
|
+
# Before any ruleset was held the version is 0, which no ruleset has.
|
|
184
|
+
version = @ruleset_version.call || 0
|
|
185
|
+
events = []
|
|
186
|
+
events << LogicalEvent.new(event_id: UUID.v7, interval_start: win[:start], interval_end: win[:end],
|
|
187
|
+
ruleset_version: version, counts: rows.shift(MAX_SUMMARY_ROWS)) until rows.empty?
|
|
188
|
+
events
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def wire(event)
|
|
192
|
+
doc = { "type" => "evaluation_summary", "event_id" => event.event_id,
|
|
193
|
+
"interval_start" => EventBatch.format_time(event.interval_start),
|
|
194
|
+
"interval_end" => EventBatch.format_time(event.interval_end),
|
|
195
|
+
"ruleset_version" => event.ruleset_version,
|
|
196
|
+
"counts" => event.counts.map do |r|
|
|
197
|
+
{ "flag" => r[:flag], "variation" => r[:variation], "reason" => r[:reason].to_s, "count" => r[:count] }
|
|
198
|
+
end }
|
|
199
|
+
doc["app_version"] = @app_version if @app_version
|
|
200
|
+
doc
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def public_summary(event)
|
|
204
|
+
Recorder::EvaluationSummary.new(
|
|
205
|
+
event_id: event.event_id, interval_start: event.interval_start, interval_end: event.interval_end,
|
|
206
|
+
ruleset_version: event.ruleset_version, app_version: @app_version,
|
|
207
|
+
counts: event.counts.map do |r|
|
|
208
|
+
Recorder::SummaryCount.new(flag: r[:flag], variation: r[:variation],
|
|
209
|
+
reason: r[:reason], count: r[:count])
|
|
210
|
+
end
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# window_of rebuilds a window from the summary events that were not
|
|
215
|
+
# delivered, so their rows merge into the next one.
|
|
216
|
+
def window_of(wire_events)
|
|
217
|
+
counts = Hash.new(0)
|
|
218
|
+
start_t = nil
|
|
219
|
+
end_t = nil
|
|
220
|
+
wire_events.each do |e|
|
|
221
|
+
s = Time.parse(e["interval_start"])
|
|
222
|
+
en = Time.parse(e["interval_end"])
|
|
223
|
+
start_t = s if start_t.nil? || s < start_t
|
|
224
|
+
end_t = en if end_t.nil? || en > end_t
|
|
225
|
+
e["counts"].each { |r| counts[[r["flag"], r["variation"], r["reason"].to_sym]] += r["count"] }
|
|
226
|
+
end
|
|
227
|
+
{ start: start_t || Time.now, end: end_t || Time.now, counts: counts }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# schedule_retry honours Retry-After, capped at the ordinary backoff
|
|
231
|
+
# ceiling: a header naming minutes must not silence reporting for
|
|
232
|
+
# minutes (or, from a misbehaving proxy, far longer), so it bounds
|
|
233
|
+
# rather than replaces the backoff (the protocol allows either
|
|
234
|
+
# choice; Exposures ignores the header entirely instead).
|
|
235
|
+
def schedule_retry(group, result)
|
|
236
|
+
wait = if result.retry_after
|
|
237
|
+
[result.retry_after,
|
|
238
|
+
@timing.fetch_cap].min
|
|
239
|
+
else
|
|
240
|
+
@backoff.next_delay(cap: @timing.fetch_cap)
|
|
241
|
+
end
|
|
242
|
+
@not_before = now + wait
|
|
243
|
+
log_ingest_failed(group, result, wait)
|
|
244
|
+
wait
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def report_accepted(group, result)
|
|
248
|
+
return if result.dropped_rows.to_i.zero? && result.rejected.to_a.empty?
|
|
249
|
+
|
|
250
|
+
log(:warn, :queue_dropped, kind: kind, cause: "refused_by_the_platform",
|
|
251
|
+
events: group.map { |e| e["event_id"] }, accepted: result.accepted,
|
|
252
|
+
duplicates: result.duplicates, dropped_rows: result.dropped_rows,
|
|
253
|
+
rejected: result.rejected.map { |r| r["code"] })
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
private_constant :Summaries
|
|
257
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../flagnetic"
|
|
5
|
+
|
|
6
|
+
module Flagnetic
|
|
7
|
+
# Testing builds offline clients for deterministic tests: from a ruleset
|
|
8
|
+
# document, a file, or a Hash of flag key to value. No client it builds
|
|
9
|
+
# ever touches the network; the recorder holds what it would report.
|
|
10
|
+
#
|
|
11
|
+
# require "flagnetic/testing"
|
|
12
|
+
#
|
|
13
|
+
# client = Flagnetic::Testing.offline(values: { "new_checkout" => true })
|
|
14
|
+
# client.set("new_checkout", false) # now served to everyone as fallthrough
|
|
15
|
+
# client.recorder.summary # evaluation counts
|
|
16
|
+
#
|
|
17
|
+
# Helpers gives RSpec examples and Minitest tests with_flags and
|
|
18
|
+
# with_ruleset, which swap such a client in as Flagnetic.client for a
|
|
19
|
+
# block.
|
|
20
|
+
module Testing
|
|
21
|
+
# VARIATION names the one variation set and values: give a flag.
|
|
22
|
+
VARIATION = "value"
|
|
23
|
+
private_constant :VARIATION
|
|
24
|
+
|
|
25
|
+
# offline builds an OfflineClient from a ruleset document (JSON text or
|
|
26
|
+
# a Hash, exactly as the ruleset endpoint serves it), from file:, or
|
|
27
|
+
# from values:, a Hash of flag key to value where the value's class
|
|
28
|
+
# decides the flag type. Other options go to Client.new. It raises
|
|
29
|
+
# OfflineError when the document does not load.
|
|
30
|
+
def self.offline(document = nil, file: nil, values: nil, **)
|
|
31
|
+
given = [document, file, values].compact.size
|
|
32
|
+
raise ArgumentError, "pass one of a document, file: or values:" unless given == 1
|
|
33
|
+
|
|
34
|
+
source =
|
|
35
|
+
if file then -> { File.binread(file) }
|
|
36
|
+
elsif values then document_from(values).then { |doc| -> { doc } }
|
|
37
|
+
else
|
|
38
|
+
(document.is_a?(Hash) ? JSON.generate(document) : document.to_s).then { |doc| -> { doc } }
|
|
39
|
+
end
|
|
40
|
+
OfflineClient.new(source, **)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# document_from builds a ruleset document serving each value as the
|
|
44
|
+
# fallthrough of a flag with one variation.
|
|
45
|
+
def self.document_from(values)
|
|
46
|
+
doc = { "schema_version" => 1, "version" => 1, "scope" => "server", "flags" => [] }
|
|
47
|
+
values.each { |key, value| set_flag(doc, key.to_s, value) }
|
|
48
|
+
JSON.generate(doc)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# set_flag replaces or adds the flag key in a parsed ruleset document.
|
|
52
|
+
# An existing flag keeps its type and value must be of it.
|
|
53
|
+
def self.set_flag(doc, key, value)
|
|
54
|
+
flags = (doc["flags"] ||= [])
|
|
55
|
+
at = flags.index { |f| f.is_a?(Hash) && f["key"] == key }
|
|
56
|
+
type = Client.__send__(:type_of, value)
|
|
57
|
+
if at && (existing = flags[at]["type"]) != "json" && existing != type
|
|
58
|
+
raise ArgumentError, "flag #{key.inspect} is #{existing}, a #{value.class} is #{type}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
type = flags[at]["type"] if at
|
|
62
|
+
flag = { "key" => key, "type" => type, "on" => true, "variations" => [{ "key" => VARIATION, "value" => value }],
|
|
63
|
+
"off_variation" => VARIATION, "fallthrough" => VARIATION }
|
|
64
|
+
at ? flags[at] = flag : flags << flag
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# with_flags makes an offline client serving the given flag values
|
|
68
|
+
# Flagnetic.client for the block, as offline(values:) builds it, and
|
|
69
|
+
# restores the previous one afterwards, even when the block raises; a
|
|
70
|
+
# client the Rails integration had not built yet stays unbuilt. Keys
|
|
71
|
+
# may be Strings or Symbols. It yields the client, whose recorder holds
|
|
72
|
+
# what the code under test reported. The swap is process-wide: tests
|
|
73
|
+
# running in parallel threads must not use it.
|
|
74
|
+
#
|
|
75
|
+
# Flagnetic::Testing.with_flags("new_checkout" => true) do |client|
|
|
76
|
+
# get "/checkout"
|
|
77
|
+
# assert client.recorder.summary.any? { |row| row.flag == "new_checkout" }
|
|
78
|
+
# end
|
|
79
|
+
def self.with_flags(values = {}, **more, &)
|
|
80
|
+
swap(offline(values: values.merge(more)), &)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# with_ruleset is with_flags for a ruleset document (JSON text or a
|
|
84
|
+
# Hash, exactly as the ruleset endpoint serves it) or file:, for a test
|
|
85
|
+
# needing rules, rollouts or an experiment: the recorder then holds the
|
|
86
|
+
# exposures the code under test reported.
|
|
87
|
+
def self.with_ruleset(document = nil, file: nil, &)
|
|
88
|
+
raise ArgumentError, "pass a document or file:" if document.nil? == file.nil?
|
|
89
|
+
|
|
90
|
+
swap(offline(document, file: file), &)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.swap(client)
|
|
94
|
+
raise ArgumentError, "a block is required" unless block_given?
|
|
95
|
+
|
|
96
|
+
previous = Flagnetic.__send__(:replace_client, client)
|
|
97
|
+
begin
|
|
98
|
+
yield client
|
|
99
|
+
ensure
|
|
100
|
+
Flagnetic.__send__(:replace_client, previous.first)
|
|
101
|
+
client.close(timeout: 0)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private_class_method :document_from, :set_flag, :swap
|
|
105
|
+
|
|
106
|
+
# Helpers is with_flags and with_ruleset for test classes:
|
|
107
|
+
#
|
|
108
|
+
# RSpec.configure { |config| config.include Flagnetic::Testing::Helpers }
|
|
109
|
+
#
|
|
110
|
+
# class ActiveSupport::TestCase
|
|
111
|
+
# include Flagnetic::Testing::Helpers
|
|
112
|
+
# end
|
|
113
|
+
module Helpers
|
|
114
|
+
# with_flags is Flagnetic::Testing.with_flags.
|
|
115
|
+
def with_flags(...) = Testing.with_flags(...)
|
|
116
|
+
|
|
117
|
+
# with_ruleset is Flagnetic::Testing.with_ruleset.
|
|
118
|
+
def with_ruleset(...) = Testing.with_ruleset(...)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# OfflineClient is an offline Client whose document a test can rewrite.
|
|
122
|
+
class OfflineClient < Client
|
|
123
|
+
def initialize(read, **)
|
|
124
|
+
@read = read
|
|
125
|
+
@document_mutex = Mutex.new
|
|
126
|
+
@document = read.call
|
|
127
|
+
begin
|
|
128
|
+
Eval::Ruleset.decode(@document)
|
|
129
|
+
rescue Eval::DecodeError, TypeError => e
|
|
130
|
+
raise OfflineError, "offline ruleset: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
super(offline: -> { @document_mutex.synchronize { @document } }, **)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
alias reload_document reload
|
|
136
|
+
private :reload_document
|
|
137
|
+
|
|
138
|
+
# set rewrites the flag key to serve value to everyone: one variation,
|
|
139
|
+
# served as fallthrough, so the reason reads fallthrough. A key the
|
|
140
|
+
# document lacks is added, typed by the value; an existing flag keeps
|
|
141
|
+
# its type. on_change fires.
|
|
142
|
+
def set(key, value)
|
|
143
|
+
@document_mutex.synchronize do
|
|
144
|
+
doc = JSON.parse(@document)
|
|
145
|
+
Testing.__send__(:set_flag, doc, key.to_s, value)
|
|
146
|
+
@document = JSON.generate(doc)
|
|
147
|
+
end
|
|
148
|
+
reload_document
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# reload re-reads the source the client was built from, discarding
|
|
152
|
+
# what set changed. on_change fires with the flags that changed.
|
|
153
|
+
def reload
|
|
154
|
+
document = @read.call
|
|
155
|
+
@document_mutex.synchronize { @document = document }
|
|
156
|
+
reload_document
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Flagnetic
|
|
7
|
+
# Transport speaks the SDK API under SDK_API_PATH for one server key. Every
|
|
8
|
+
# request, the change stream included, goes through Net::HTTP, so the
|
|
9
|
+
# proxy the process is configured with (the http_proxy, https_proxy and
|
|
10
|
+
# no_proxy environment variables) applies to all of them.
|
|
11
|
+
class Transport
|
|
12
|
+
# Response is the outcome of a ruleset fetch; body is decompressed.
|
|
13
|
+
Response = Struct.new(:status, :body, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
# EventsResponse is the outcome of an events POST; EventBatch classifies
|
|
16
|
+
# it. body is decompressed; retry_after is the raw header value.
|
|
17
|
+
EventsResponse = Struct.new(:status, :body, :retry_after, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
attr_reader :stream_uri, :timeout
|
|
20
|
+
|
|
21
|
+
def initialize(sdk_key:, base_url:, platform:, timeout:)
|
|
22
|
+
base = URI(base_url.to_s.chomp("/"))
|
|
23
|
+
raise ArgumentError, "base_url must be an http or https URL, got #{base_url.inspect}" unless
|
|
24
|
+
base.is_a?(URI::HTTP) && base.host
|
|
25
|
+
|
|
26
|
+
@ruleset_uri = URI("#{base}#{SDK_API_PATH}/ruleset")
|
|
27
|
+
@stream_uri = URI("#{base}#{SDK_API_PATH}/stream")
|
|
28
|
+
@events_uri = URI("#{base}#{SDK_API_PATH}/events")
|
|
29
|
+
@platform = platform
|
|
30
|
+
@sdk_key = sdk_key.to_s
|
|
31
|
+
@user_agent = "flagnetic-ruby/#{VERSION} (#{platform})"
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# identity is this SDK's entry in an events envelope's sdk member.
|
|
36
|
+
def identity
|
|
37
|
+
{ "name" => "flagnetic-ruby", "version" => VERSION, "platform" => @platform }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# post_events POSTs a gzipped events batch and returns the raw
|
|
41
|
+
# response; EventBatch classifies it. It raises on network errors and
|
|
42
|
+
# timeouts, as fetch_ruleset does. timeout bounds this one call, so a
|
|
43
|
+
# flush bounded by a budget shorter than the client's own
|
|
44
|
+
# request_timeout still returns within it.
|
|
45
|
+
def post_events(gzipped_body, timeout = @timeout)
|
|
46
|
+
request = Net::HTTP::Post.new(@events_uri)
|
|
47
|
+
headers.each { |k, v| request[k] = v }
|
|
48
|
+
request["Content-Type"] = "application/json"
|
|
49
|
+
request["Content-Encoding"] = "gzip"
|
|
50
|
+
request["Flagnetic-Schema-Version"] = SCHEMA_VERSION_MAX.to_s
|
|
51
|
+
request.body = gzipped_body
|
|
52
|
+
response = http_for(@events_uri, timeout).start { |h| h.request(request) }
|
|
53
|
+
EventsResponse.new(status: response.code.to_i, body: response.body.to_s, retry_after: response["Retry-After"])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# fetch_ruleset GETs the ruleset, revalidating the held version with
|
|
57
|
+
# If-None-Match. It raises on network errors and timeouts; Net::HTTP
|
|
58
|
+
# asks for and inflates gzip.
|
|
59
|
+
def fetch_ruleset(held_version)
|
|
60
|
+
request = Net::HTTP::Get.new(@ruleset_uri)
|
|
61
|
+
headers.each { |k, v| request[k] = v }
|
|
62
|
+
request["Accept"] = "application/json"
|
|
63
|
+
request["Flagnetic-Schema-Version"] = SCHEMA_VERSION_MAX.to_s
|
|
64
|
+
request["If-None-Match"] = %("#{held_version}") if held_version
|
|
65
|
+
http = http_for(@ruleset_uri, @timeout)
|
|
66
|
+
response = http.start { |h| h.request(request) }
|
|
67
|
+
Response.new(status: response.code.to_i, body: response.body.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# stream_request is the change stream's request; Last-Event-ID is the
|
|
71
|
+
# held version, so the platform emits only what is newer. The stream is
|
|
72
|
+
# asked for uncompressed: a compressed one would hold frames back until
|
|
73
|
+
# the compressor flushed.
|
|
74
|
+
def stream_request(held_version)
|
|
75
|
+
request = Net::HTTP::Get.new(@stream_uri)
|
|
76
|
+
headers.each { |k, v| request[k] = v }
|
|
77
|
+
request["Accept"] = "text/event-stream"
|
|
78
|
+
request["Cache-Control"] = "no-cache"
|
|
79
|
+
request["Accept-Encoding"] = "identity"
|
|
80
|
+
request["Last-Event-ID"] = held_version.to_s if held_version
|
|
81
|
+
request
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# http_for builds the connection to uri's host. Net::HTTP.new takes the
|
|
85
|
+
# proxy from the environment, honouring no_proxy; retries are off
|
|
86
|
+
# because a failed request is retried by the client's backoff alone.
|
|
87
|
+
def http_for(uri, timeout)
|
|
88
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
89
|
+
http.use_ssl = uri.scheme == "https"
|
|
90
|
+
http.open_timeout = http.read_timeout = http.write_timeout = http.ssl_timeout = timeout
|
|
91
|
+
http.max_retries = 0
|
|
92
|
+
http
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# inspect leaves the server key out, so a client printed in a console
|
|
96
|
+
# or attached to an error report does not disclose it.
|
|
97
|
+
def inspect = "#<#{self.class} #{@ruleset_uri.host}:#{@ruleset_uri.port}>"
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def headers
|
|
102
|
+
{ "Authorization" => "Bearer #{@sdk_key}", "User-Agent" => @user_agent }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
private_constant :Transport
|
|
106
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Flagnetic
|
|
6
|
+
# UUID generates the event IDs of the events endpoint.
|
|
7
|
+
module UUID
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# v7 returns a lowercase UUIDv7 whose first 48 bits are milliseconds
|
|
11
|
+
# since the epoch, so the platform reads an event's time from the ID
|
|
12
|
+
# itself and drops a retry as a duplicate.
|
|
13
|
+
def v7(time: Time.now)
|
|
14
|
+
ms = [(time.to_r * 1000).to_i, 0].max
|
|
15
|
+
timestamp = [ms].pack("Q>").byteslice(2, 6) # the top 48 bits, big-endian
|
|
16
|
+
entropy = SecureRandom.random_bytes(10)
|
|
17
|
+
version_and_rand_a = ((entropy.getbyte(0) & 0x0f) | 0x70).chr
|
|
18
|
+
variant_and_rand_b = ((entropy.getbyte(1) & 0x3f) | 0x80).chr
|
|
19
|
+
bytes = timestamp + version_and_rand_a + entropy.byteslice(2, 1) + variant_and_rand_b + entropy.byteslice(3, 7)
|
|
20
|
+
hex = bytes.unpack1("H*")
|
|
21
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
private_constant :UUID
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Flagnetic
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
|
|
6
|
+
# The schema_version range of the documents this SDK reads and the SDK API
|
|
7
|
+
# path it speaks. A ruleset outside the range is a fallback chain failure,
|
|
8
|
+
# logged once as schema_unsupported; every ruleset and events request asks
|
|
9
|
+
# for SCHEMA_VERSION_MAX in Flagnetic-Schema-Version (the change stream
|
|
10
|
+
# carries no document). A change to any of these needs a row in
|
|
11
|
+
# docs/sdks/compatibility.md, and a new SDK_API_PATH is a new major
|
|
12
|
+
# VERSION.
|
|
13
|
+
SCHEMA_VERSION_MIN = 1
|
|
14
|
+
SCHEMA_VERSION_MAX = 1
|
|
15
|
+
SDK_API_PATH = "/sdk/v1"
|
|
16
|
+
end
|